From 53801544e8a7daedc8f5bca13f20af9d8abb6aaf Mon Sep 17 00:00:00 2001 From: LoadCG Date: Sat, 3 Oct 2026 01:12:21 -0300 Subject: [PATCH 1/5] feat(search): close S2 ingestion and evaluation candidate --- .env.example | 9 + .gitignore | 4 + README.md | 8 + ai-service/config.py | 9 + ai-service/evaluation/README.md | 102 +++++ .../evaluation/datasets/search-ptbr-v1.json | 202 +++++++++ .../evaluation/datasets/search-ptbr-v2.json | 260 ++++++++++++ ai-service/evaluation/evaluate_search.py | 266 ++++++++++++ ai-service/evaluation/run_search_suite.py | 209 ++++++++++ ai-service/main.py | 114 ++++- ai-service/requirements.txt | 2 + ai-service/services/chunker.py | 57 +-- ai-service/tests/test_chunker.py | 15 + ai-service/tests/test_document_ingestion.py | 218 ++++++++++ ai-service/tests/test_search_evaluation.py | 220 ++++++++++ ai-service/tests/test_search_suite_runner.py | 102 +++++ backend/package.json | 3 +- backend/src/config/env.test.ts | 25 ++ backend/src/config/env.ts | 22 +- backend/src/database/seed-lib.ts | 22 +- backend/src/database/seed.test.ts | 12 + .../documents/document-ingestion.test.ts | 83 ++++ .../modules/documents/document-ingestion.ts | 62 +++ .../modules/documents/documents.controller.ts | 9 + .../src/modules/documents/documents.fakes.ts | 41 ++ .../documents/documents.repository.db.test.ts | 101 ++++- .../modules/documents/documents.repository.ts | 117 ++++++ .../documents/documents.routes.test.ts | 10 + .../src/modules/documents/documents.routes.ts | 2 + .../documents/documents.service.test.ts | 50 +++ .../modules/documents/documents.service.ts | 32 +- .../modules/documents/documents.storage.ts | 7 +- .../src/modules/documents/documents.types.ts | 16 + .../search/search.repository.db.test.ts | 93 +++++ .../modules/search/search.repository.test.ts | 53 +++ .../src/modules/search/search.repository.ts | 131 ++++++ .../src/modules/search/search.routes.test.ts | 69 ++++ backend/src/modules/search/search.routes.ts | 98 ++--- .../src/modules/search/search.service.test.ts | 117 ++++++ backend/src/modules/search/search.service.ts | 69 ++++ backend/src/modules/search/search.types.ts | 32 ++ database/init.sql | 10 + database/migrations/014_hybrid_search.sql | 3 + .../migrations/015_document_ingestion.sql | 10 + .../017_document_ingestion_lease_fencing.sql | 3 + database/seed/README.md | 7 +- database/seed/curated/v2/api-1-backlog-23.md | 9 + database/seed/curated/v2/api-1-backlog-35.md | 9 + database/seed/curated/v2/api-2-us-01.md | 9 + database/seed/curated/v2/api-2-us-02.md | 9 + database/seed/curated/v2/api-3-grf-01.md | 9 + database/seed/curated/v2/api-3-grf-08.md | 9 + database/seed/fixtures/historical-v2.json | 389 ++++++++++++++++++ docker-compose.yml | 11 +- docs/DOCUMENTOS_INTEGRACAO.md | 52 ++- docs/PLANO_FECHAMENTO_PR_UNICO_S2.md | 230 +++++++++++ docs/QA_SEARCH_V2_2026-10-02.md | 99 +++++ docs/README.md | 6 +- docs/RELATORIO_FINAL_QA_S2_2026-10-02.md | 95 +++++ docs/SETUP_GUIDE.md | 6 +- docs/STATUS_REVISAO_2026-10-02.md | 81 ++++ docs/api/openapi.yaml | 88 +++- frontend/src/api/api_documents.ts | 9 + .../src/assets/styles/garakis-prototype.css | 17 + .../views/documents/DocumentsView.test.tsx | 42 +- .../src/views/documents/DocumentsView.tsx | 33 +- .../views/knowledge/KnowledgeView.test.tsx | 58 +++ .../src/views/knowledge/KnowledgeView.tsx | 192 +++++---- scripts/smoke_document_lifecycle.py | 229 +++++++++++ 69 files changed, 4580 insertions(+), 217 deletions(-) create mode 100644 ai-service/evaluation/README.md create mode 100644 ai-service/evaluation/datasets/search-ptbr-v1.json create mode 100644 ai-service/evaluation/datasets/search-ptbr-v2.json create mode 100644 ai-service/evaluation/evaluate_search.py create mode 100644 ai-service/evaluation/run_search_suite.py create mode 100644 ai-service/tests/test_document_ingestion.py create mode 100644 ai-service/tests/test_search_evaluation.py create mode 100644 ai-service/tests/test_search_suite_runner.py create mode 100644 backend/src/modules/documents/document-ingestion.test.ts create mode 100644 backend/src/modules/documents/document-ingestion.ts create mode 100644 backend/src/modules/search/search.repository.db.test.ts create mode 100644 backend/src/modules/search/search.repository.test.ts create mode 100644 backend/src/modules/search/search.repository.ts create mode 100644 backend/src/modules/search/search.routes.test.ts create mode 100644 backend/src/modules/search/search.service.test.ts create mode 100644 backend/src/modules/search/search.service.ts create mode 100644 backend/src/modules/search/search.types.ts create mode 100644 database/migrations/014_hybrid_search.sql create mode 100644 database/migrations/015_document_ingestion.sql create mode 100644 database/migrations/017_document_ingestion_lease_fencing.sql create mode 100644 database/seed/curated/v2/api-1-backlog-23.md create mode 100644 database/seed/curated/v2/api-1-backlog-35.md create mode 100644 database/seed/curated/v2/api-2-us-01.md create mode 100644 database/seed/curated/v2/api-2-us-02.md create mode 100644 database/seed/curated/v2/api-3-grf-01.md create mode 100644 database/seed/curated/v2/api-3-grf-08.md create mode 100644 database/seed/fixtures/historical-v2.json create mode 100644 docs/PLANO_FECHAMENTO_PR_UNICO_S2.md create mode 100644 docs/QA_SEARCH_V2_2026-10-02.md create mode 100644 docs/RELATORIO_FINAL_QA_S2_2026-10-02.md create mode 100644 docs/STATUS_REVISAO_2026-10-02.md create mode 100644 frontend/src/views/knowledge/KnowledgeView.test.tsx create mode 100644 scripts/smoke_document_lifecycle.py diff --git a/.env.example b/.env.example index 274b9fc..32200e1 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,13 @@ OLLAMA_EMBEDDING_MODEL=bge-m3 OLLAMA_KEEP_ALIVE=24h # URL do serviço RepoAnalyzer quando o backend roda em Docker Compose. REPO_ANALYZER_URL=http://ai-service:8000 +# Segredo para o backend autorizar chamadas internas ao endpoint de ingestão de documentos. +# Gere um segredo privado com: openssl rand -hex 32 +DOCUMENT_INGESTION_TOKEN= +# Similaridade coseno mínima para aceitar candidatos encontrados somente pelo vetor; calibrar com a bateria S2-17. +SEARCH_MIN_VECTOR_SIMILARITY=0.55 +# Ranking textual mínimo para retornar correspondências full-text; calibrar com S2-17. +SEARCH_MIN_TEXT_RANK=0.05 # Limite de arquivos priorizados nos perfis do RepoAnalyzer (Complete usa MAX_FILES). ANALYZER_QUICK_FILES=8 ANALYZER_BALANCED_FILES=80 @@ -53,6 +60,8 @@ DOCUMENT_EVENTS_WEBHOOK_URL= BACKEND_PORT=3001 FRONTEND_PORT=5173 AI_SERVICE_PORT=8000 +# Endereço de bind da porta do ai-service no host (Docker DNS continua interno) +AI_SERVICE_BIND=127.0.0.1 # --- Configurações Regionais --- GENERIC_TIMEZONE=America/Sao_Paulo diff --git a/.gitignore b/.gitignore index 54ccdce..952ddcd 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,10 @@ n8n/local-files/* htmlcov/ .pytest_cache/ +# Resultados de avaliação local da busca +ai-service/evaluation/results/ +ai-service/test_workspace/ + # armazenamento local de documentos enviados backend/storage/ storage/ diff --git a/README.md b/README.md index 4a0d131..522cfe4 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,10 @@ A primeira sprint teve como objetivo construir a **base funcional do Sinapse** e 📌 [Planejamento completo da Sprint](docs/PLANEJAMENTO_SCRUM.md) +### 📍 Estado atual do trabalho + +A Sprint 1 foi concluída em **27/09/2026**. A Sprint 2 está planejada para **05/10 a 25/10/2026**. A implementação candidata de ingestão, busca e avaliação está em [PR de revisão](https://github.com/Galaticos-API/API-4/pulls) e ainda não foi aceita: o baseline encontrou latência acima de 2 s e pendências de relevância (Q008 e Q022). Veja o [registro QA](docs/STATUS_REVISAO_2026-10-02.md) para evidências, limites e próximos passos. + --- ## 🖥️ Conheça o Sinapse @@ -289,6 +293,8 @@ cp .env.example .env ```powershell Copy-Item .env.example .env +# Gere com: python -c "import secrets; print(secrets.token_hex(32))" +# Cole o resultado na variável DOCUMENT_INGESTION_TOKEN dentro do arquivo .env ``` --- @@ -299,6 +305,8 @@ Copy-Item .env.example .env docker compose up --build -d ``` +O Compose exige esse segredo compartilhado pelo backend e pelo serviço local de IA; não há mais token padrão no código. + Verifique os containers: ```bash diff --git a/ai-service/config.py b/ai-service/config.py index 226168f..4e2e28d 100644 --- a/ai-service/config.py +++ b/ai-service/config.py @@ -1,4 +1,5 @@ from pathlib import Path +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from dotenv import load_dotenv, find_dotenv @@ -9,6 +10,8 @@ class Settings(BaseSettings): AI_SERVICE_PORT: int = 8000 AI_SERVICE_HOST: str = "0.0.0.0" + NODE_ENV: str = "development" + DOCUMENT_INGESTION_TOKEN: str = "" # Ollama integration OLLAMA_BASE_URL: str = "http://localhost:11434" @@ -24,5 +27,11 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") + @model_validator(mode="after") + def require_private_ingestion_token_in_production(self): + if self.NODE_ENV.lower() == "production" and len(self.DOCUMENT_INGESTION_TOKEN) < 32: + raise ValueError("Configure um DOCUMENT_INGESTION_TOKEN aleatório e privado em produção.") + return self + settings = Settings() diff --git a/ai-service/evaluation/README.md b/ai-service/evaluation/README.md new file mode 100644 index 0000000..c0d2907 --- /dev/null +++ b/ai-service/evaluation/README.md @@ -0,0 +1,102 @@ +# Avaliação da busca do acervo + +Esta pasta contém as baterias versionadas da S2-17, um executor HTTP e um avaliador independente do mecanismo de busca. A v1 é a referência imutável; a v2 adiciona metadados de localizador e duas consultas por conteúdo para comparar os dois jeitos de busca. + +## Privacidade e corpus + +O corpus usa recortes de documentação pública dos projetos API-1, API-2 e API-3. A política PRE-06 exclui código-fonte, configuração de IDE e anexos de teste; o repositório API-2 contém anexos de teste com dados pessoais. Esta suíte não coleta nem armazena esses anexos, nomes de autores ou dados pessoais. Consulte `database/seed/fixtures/historical-v1.json`, `database/seed/fixtures/historical-v2.json` e `docs/backlog/README.md` para a origem e as transformações documentadas. + +## Dataset + +`datasets/search-ptbr-v1.json` contém 24 consultas em português brasileiro, IDs estáveis, categoria, projeto obrigatório, filtros opcionais e IDs das evidências relevantes. Inclui paráfrases semânticas, identificadores/termos exatos, consultas com intenção combinada, consultas sem evidência, filtros de nível e casos de escopo de projeto para detectar vazamento. + +`datasets/search-ptbr-v2.json` preserva as 24 consultas e gabaritos da v1, aponta para a fixture `pre06-historical-v2` com IDs próprios, e acrescenta duas consultas de conteúdo sobre os requisitos GRF. As consultas `GRF-01` e `GRF-08` continuam na categoria de identificadores exatos. Assim medimos tanto a busca por código quanto pela descrição do que a funcionalidade faz. + +Os IDs de evidência correspondem aos IDs de `chunk` da fixture PRE-06. Toda consulta escolhe explicitamente um projeto, em linha com a regra de que distância semântica não concede acesso entre projetos. A consulta é julgada somente contra o escopo indicado; o avaliador também acusa qualquer resultado retornado de outro projeto. + +## Formato normalizado de resultados + +O executor `run_search_suite.py` converte a resposta da rota S2-06 para este formato. Cada consulta precisa aparecer exatamente uma vez; `results` mantém a ordem de ranking e os scores que a API fornecer. + +```json +{ + "dataset": "sinapse-search-ptbr", + "version": 1, + "runs": [ + { + "query_id": "Q001", + "latency_ms": 145, + "results": [ + { + "source_id": "60000000-0000-4000-8000-000000000003", + "project_id": "60000000-0000-4000-8000-000000000001" + } + ] + } + ] +} +``` + +`source_id` deve ser o identificador do chunk/evidência recuperada e `project_id` deve ser o projeto retornado junto à fonte. O avaliador compara o escopo retornado com o projeto pedido e, quando a fonte pertence ao corpus PRE-06, também verifica contra o manifesto curado. Fontes desconhecidas no corpus podem ser avaliadas como ruído, mas nunca deixam de ser verificadas quanto ao projeto. Não adapte por título ou texto aproximado: isso esconderia erros de identidade e isolamento. O avaliador falha a execução se qualquer resultado estiver fora do projeto consultado; uma violação de isolamento é bloqueadora, não somente uma métrica baixa. O relatório por consulta inclui posição, ID e score retornado para facilitar diagnóstico de falso positivo/falso negativo sem alterar o gabarito. + +## Métricas + +- `Recall@5` macro: fração das evidências relevantes encontradas entre os cinco primeiros resultados, calculada nas consultas com resposta esperada. +- `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. +- 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. + +## Executar + +Executar as 24 consultas contra o backend local (a sessão deve ter acesso ao projeto de cada consulta): + +```bash +export SINAPSE_SESSION_COOKIE="" +python ai-service/evaluation/run_search_suite.py --output ai-service/evaluation/results/search-ptbr-v1-results.json +python ai-service/evaluation/evaluate_search.py --results ai-service/evaluation/results/search-ptbr-v1-results.json +``` + +Para executar a v2, passe o dataset e grave resultados em local descartável; substitua o caminho de saída conforme sua política de retenção: + +```bash +python ai-service/evaluation/run_search_suite.py --dataset ai-service/evaluation/datasets/search-ptbr-v2.json --output tmp/search-ptbr-v2-results.json +python ai-service/evaluation/evaluate_search.py --dataset ai-service/evaluation/datasets/search-ptbr-v2.json --results tmp/search-ptbr-v2-results.json +``` + +O executor lê a credencial de `SINAPSE_SESSION_COOKIE`, envia `projeto_id` em toda chamada, cronometra a latência HTTP real, e não imprime nem grava a credencial. `SINAPSE_API_BASE_URL` altera a URL base (padrão `http://localhost:3001`), mas por segurança só aceita HTTP em `localhost` ou endereço loopback; cookies de sessão nunca são enviados para hosts remotos. O executor registra SHA do commit, se o working tree estava modificado, hash do snapshot de arquivos versionados/não ignorados, hash do dataset e do arquivo de corpus. `.env*` e `tmp/` ficam fora do hash e do relatório. O arquivo de resultados não deve ser commitado quando incluir nomes, conteúdo ou outros dados que não sejam da fixture aprovada. + +Também é possível fornecer resultados normalizados de outro executor: + +```bash +python ai-service/evaluation/evaluate_search.py --results caminho/para/resultados.json +``` + +Por padrão calcula métricas em `k=5` e orçamento de 2.000 ms. Os parâmetros `--k`, `--latency-budget-ms` e `--dataset` permitem reproduzir outra execução sem alterar o dataset versionado. A ferramenta retorna código 2 para arquivos, versões, consultas ou resultados inválidos. + +Testar o avaliador sem Ollama, Postgres ou endpoint S2-06: + +```bash +python -m unittest discover -s ai-service/tests -p "test_search*.py" +``` + +Com Postgres descartável que já tenha pgvector instalado e banco terminado em `_test`, o teste S2-06 de integração pode ser executado dentro de `backend` após definir `HYBRID_SEARCH_TEST_DATABASE_URL`: + +```bash +npm run test:integration:search +``` + +## Baseline ponta a ponta executada; aceite de relevância pendente + +Em 02/10/2026 o executor rodou as 24 consultas contra API, PostgreSQL/pgvector e Ollama locais, usando embeddings da fixture PRE-06. A execução e a comparação de corte foram feitas em banco descartável; veja números, limitações e plano de ação no [registro da revisão QA](../../docs/STATUS_REVISAO_2026-10-02.md). + +Na execução inicial da v1, `SEARCH_MIN_VECTOR_SIMILARITY=0.3` obteve Recall@5 de 88,9%, acurácia de ausência de 16,7%, p95 de 145 ms e zero violações de isolamento. O experimento v1 com `0.6` elevou a acurácia de ausência para 100%, mas baixou Recall@5 para 72,2%. Esses números são referências históricas da v1; os resultados atuais da v2 e a matriz de cortes estão no [relatório da avaliação](../../docs/QA_SEARCH_V2_2026-10-02.md). `0.55` foi escolhido como padrão local provisório, pendente de reavaliação com corpus maior e metas formais de relevância. + +Queries com formato de identificador (`GRF-01`) usam correspondência literal sobre `metadata.source_locator` e não recebem fallback semântico quando não há correspondência. O avaliador também rejeita qualquer resultado fora do projeto solicitado. A v2 com identificadores e perguntas de conteúdo foi executada ponta a ponta; `0.55` é o padrão local provisório escolhido pelo usuário, ainda sem aceite final das metas de qualidade. Diagnóstico da avaliação v2 e limitações remanescentes estão no [relatório QA](../../docs/QA_SEARCH_V2_2026-10-02.md). O runner atual exige URL local, registra hashes de proveniência e o avaliador mostra o ranking por consulta. Após qualquer mudança no runner, corpus, configuração ou busca, reexecute a bateria real contra a API e preserve apenas relatório sanitizado. + +Não modifique silenciosamente versão publicada do corpus ou gabarito. Alterações exigem revisão e nova versão do dataset/corpus. diff --git a/ai-service/evaluation/datasets/search-ptbr-v1.json b/ai-service/evaluation/datasets/search-ptbr-v1.json new file mode 100644 index 0000000..4670a5b --- /dev/null +++ b/ai-service/evaluation/datasets/search-ptbr-v1.json @@ -0,0 +1,202 @@ +{ + "dataset": "sinapse-search-ptbr", + "version": 1, + "corpus": { + "dataset": "pre06-historical-v1", + "file": "database/seed/fixtures/historical-v1.json", + "source_policy": "Somente documentação pública curada dos projetos API-1, API-2 e API-3; sem código-fonte, anexos de teste ou dados pessoais." + }, + "source_projects": { + "60000000-0000-4000-8000-000000000001": "API-1", + "60000000-0000-4000-8000-000000000006": "API-2", + "60000000-0000-4000-8000-000000000011": "API-3" + }, + "source_project_ids": { + "60000000-0000-4000-8000-000000000003": "60000000-0000-4000-8000-000000000001", + "60000000-0000-4000-8000-000000000005": "60000000-0000-4000-8000-000000000001", + "60000000-0000-4000-8000-000000000008": "60000000-0000-4000-8000-000000000006", + "60000000-0000-4000-8000-000000000010": "60000000-0000-4000-8000-000000000006", + "60000000-0000-4000-8000-000000000013": "60000000-0000-4000-8000-000000000011", + "60000000-0000-4000-8000-000000000015": "60000000-0000-4000-8000-000000000011" + }, + "source_entity_types": { + "60000000-0000-4000-8000-000000000003": "documento", + "60000000-0000-4000-8000-000000000005": "documento", + "60000000-0000-4000-8000-000000000008": "documento", + "60000000-0000-4000-8000-000000000010": "documento", + "60000000-0000-4000-8000-000000000013": "documento", + "60000000-0000-4000-8000-000000000015": "documento" + }, + "queries": [ + { + "id": "Q001", + "category": "semantic_paraphrase", + "query": "Como os administradores entram na plataforma?", + "project_id": "60000000-0000-4000-8000-000000000001", + "expected_source_ids": ["60000000-0000-4000-8000-000000000003"] + }, + { + "id": "Q002", + "category": "semantic_paraphrase", + "query": "Gerenciar as permissões de acesso de quem administra o sistema", + "project_id": "60000000-0000-4000-8000-000000000001", + "expected_source_ids": ["60000000-0000-4000-8000-000000000003"] + }, + { + "id": "Q003", + "category": "semantic_paraphrase", + "query": "O RH pode criar, consultar, editar e desativar contas de pessoas?", + "project_id": "60000000-0000-4000-8000-000000000006", + "expected_source_ids": ["60000000-0000-4000-8000-000000000008"] + }, + { + "id": "Q004", + "category": "semantic_paraphrase", + "query": "Preciso manter o plano de desenvolvimento de cada colaborador por exercício", + "project_id": "60000000-0000-4000-8000-000000000006", + "expected_source_ids": ["60000000-0000-4000-8000-000000000010"] + }, + { + "id": "Q005", + "category": "semantic_paraphrase", + "query": "Acompanhar em gráficos como a equipe está sendo avaliada", + "project_id": "60000000-0000-4000-8000-000000000001", + "expected_source_ids": ["60000000-0000-4000-8000-000000000005"] + }, + { + "id": "Q006", + "category": "semantic_paraphrase", + "query": "Visualizar a evolução do saldo de crédito do país", + "project_id": "60000000-0000-4000-8000-000000000011", + "expected_source_ids": ["60000000-0000-4000-8000-000000000013"] + }, + { + "id": "Q007", + "category": "semantic_paraphrase", + "query": "Comparar os estados pela pontuação de oportunidades", + "project_id": "60000000-0000-4000-8000-000000000011", + "expected_source_ids": ["60000000-0000-4000-8000-000000000015"] + }, + { + "id": "Q008", + "category": "semantic_paraphrase", + "query": "Qual funcionalidade registra um PDI anual ligado ao funcionário?", + "project_id": "60000000-0000-4000-8000-000000000006", + "expected_source_ids": ["60000000-0000-4000-8000-000000000010"] + }, + { + "id": "Q009", + "category": "exact_identifier", + "query": "GRF-01", + "project_id": "60000000-0000-4000-8000-000000000011", + "expected_source_ids": ["60000000-0000-4000-8000-000000000013"] + }, + { + "id": "Q010", + "category": "exact_identifier", + "query": "GRF-08", + "project_id": "60000000-0000-4000-8000-000000000011", + "expected_source_ids": ["60000000-0000-4000-8000-000000000015"] + }, + { + "id": "Q011", + "category": "exact_identifier", + "query": "20539", + "project_id": "60000000-0000-4000-8000-000000000011", + "expected_source_ids": ["60000000-0000-4000-8000-000000000013"] + }, + { + "id": "Q012", + "category": "exact_identifier", + "query": "20539, 20540 e 20541", + "project_id": "60000000-0000-4000-8000-000000000011", + "expected_source_ids": ["60000000-0000-4000-8000-000000000013"] + }, + { + "id": "Q013", + "category": "exact_term", + "query": "índice de oportunidade", + "project_id": "60000000-0000-4000-8000-000000000011", + "expected_source_ids": ["60000000-0000-4000-8000-000000000015"] + }, + { + "id": "Q014", + "category": "exact_term", + "query": "login para administradores", + "project_id": "60000000-0000-4000-8000-000000000001", + "expected_source_ids": ["60000000-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": "60000000-0000-4000-8000-000000000011", + "expected_source_ids": ["60000000-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": "60000000-0000-4000-8000-000000000001", + "expected_source_ids": ["60000000-0000-4000-8000-000000000005"] + }, + { + "id": "Q017", + "category": "no_relevant_result", + "query": "Integração de pagamentos via PIX com conciliação bancária", + "project_id": "60000000-0000-4000-8000-000000000011", + "expected_source_ids": [] + }, + { + "id": "Q018", + "category": "no_relevant_result", + "query": "Autenticação OAuth com refresh token e PKCE", + "project_id": "60000000-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": "60000000-0000-4000-8000-000000000006", + "expected_source_ids": [] + }, + { + "id": "Q020", + "category": "project_isolation", + "query": "Gráficos de avaliação e desempenho da equipe", + "project_id": "60000000-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": "60000000-0000-4000-8000-000000000001", + "expected_source_ids": ["60000000-0000-4000-8000-000000000003"] + }, + { + "id": "Q022", + "category": "project_isolation", + "query": "Saldo de crédito representado por gráfico de linha", + "project_id": "60000000-0000-4000-8000-000000000001", + "expected_source_ids": [] + }, + { + "id": "Q023", + "category": "combined_filters", + "query": "Acesso dos administradores ao sistema", + "project_id": "60000000-0000-4000-8000-000000000001", + "filters": { "level": "documento" }, + "expected_source_ids": ["60000000-0000-4000-8000-000000000003"] + }, + { + "id": "Q024", + "category": "combined_filters", + "query": "Acesso dos administradores ao sistema", + "project_id": "60000000-0000-4000-8000-000000000001", + "filters": { "level": "pbi" }, + "expected_source_ids": [] + } + ] +} diff --git a/ai-service/evaluation/datasets/search-ptbr-v2.json b/ai-service/evaluation/datasets/search-ptbr-v2.json new file mode 100644 index 0000000..be8c3c5 --- /dev/null +++ b/ai-service/evaluation/datasets/search-ptbr-v2.json @@ -0,0 +1,260 @@ +{ + "dataset": "sinapse-search-ptbr", + "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" + ] + } + ] +} diff --git a/ai-service/evaluation/evaluate_search.py b/ai-service/evaluation/evaluate_search.py new file mode 100644 index 0000000..5367226 --- /dev/null +++ b/ai-service/evaluation/evaluate_search.py @@ -0,0 +1,266 @@ +"""Evaluate normalized retrieval results against a versioned gold dataset.""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +from pathlib import Path +from typing import Any + + +DEFAULT_DATASET = Path(__file__).parent / "datasets" / "search-ptbr-v1.json" +DEFAULT_LATENCY_BUDGET_MS = 2000 + + +def load_json(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as file: + return json.load(file) + + +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: + raise ValueError("dataset.version deve ser um inteiro positivo") + queries = dataset.get("queries") + if not isinstance(queries, list) or len(queries) < minimum_queries: + raise ValueError(f"dataset deve conter pelo menos {minimum_queries} consultas") + + source_projects = dataset.get("source_projects") + if not isinstance(source_projects, dict) or not source_projects or any(not isinstance(key, str) or not isinstance(value, str) or not value.strip() for key, value in source_projects.items()): + raise ValueError("dataset.source_projects deve mapear IDs para nomes de projeto") + project_ids = set(source_projects) + source_project_ids = dataset.get("source_project_ids") + if not isinstance(source_project_ids, dict) or not source_project_ids or any(not isinstance(key, str) or not isinstance(value, str) for key, value in source_project_ids.items()): + raise ValueError("dataset.source_project_ids deve mapear cada evidência ao projeto de origem") + if not set(source_project_ids.values()) <= project_ids: + raise ValueError("source_project_ids contém projeto ausente do catálogo") + 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: + raise ValueError("source_entity_types contém nível inválido") + query_ids: set[str] = set() + for query in queries: + if not isinstance(query, dict): + raise ValueError("cada consulta deve ser um objeto") + query_id = query.get("id") + if not isinstance(query_id, str) or not query_id.strip() or query_id in query_ids: + raise ValueError("cada consulta precisa ter um id único e não vazio") + query_ids.add(query_id) + if not isinstance(query.get("query"), str) or not query["query"].strip(): + raise ValueError(f"{query_id}: consulta vazia") + 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") + if len(expected) != len(set(expected)): + raise ValueError(f"{query_id}: fonte esperada duplicada") + if not set(expected) <= set(source_project_ids): + raise ValueError(f"{query_id}: fonte esperada não consta no manifesto do corpus") + project_id = query.get("project_id") + if not isinstance(project_id, str) or project_id not in project_ids: + raise ValueError(f"{query_id}: project_id válido é obrigatório para manter o isolamento") + if any(source_project_ids[source_id] != project_id for source_id in expected): + raise ValueError(f"{query_id}: evidência esperada pertence a outro projeto") + 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: + 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") + if filters.get("technology_id") is not None and (not isinstance(filters["technology_id"], str) or not filters["technology_id"].strip()): + raise ValueError(f"{query_id}: technology_id deve ser texto não vazio") + + +def _percentile(values: list[float], percentile: float) -> float: + ordered = sorted(values) + rank = max(1, math.ceil(percentile * len(ordered))) + return ordered[rank - 1] + + +def evaluate(dataset: dict[str, Any], results_document: dict[str, Any], k: int = 5, + latency_budget_ms: int = DEFAULT_LATENCY_BUDGET_MS) -> dict[str, Any]: + validate_dataset(dataset) + if not isinstance(k, int) or isinstance(k, bool) or k < 1 or not isinstance(latency_budget_ms, int) or isinstance(latency_budget_ms, bool) or latency_budget_ms < 0: + raise ValueError("k deve ser positivo e o orçamento de latência não pode ser negativo") + if not isinstance(results_document, dict): + 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"]: + raise ValueError("a versão dos resultados não corresponde ao dataset") + + expected_by_id = {query["id"]: query for query in dataset["queries"]} + runs = results_document.get("runs") + if not isinstance(runs, list): + raise ValueError("results.runs deve ser uma lista") + actual_by_id: dict[str, dict[str, Any]] = {} + for run in runs: + 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: + 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}") + latency_value = run.get("latency_ms") + if isinstance(latency_value, bool) or not isinstance(latency_value, (int, float)) or not math.isfinite(latency_value) or latency_value < 0: + raise ValueError(f"{query_id}: latency_ms deve ser um número não negativo") + items = run.get("results") + if not isinstance(items, list): + raise ValueError(f"{query_id}: results deve ser uma lista") + seen: set[str] = set() + for item in items: + if not isinstance(item, dict): + raise ValueError(f"{query_id}: cada resultado deve ser um objeto") + if not isinstance(item, dict) or not isinstance(item.get("source_id"), str) or not item["source_id"]: + raise ValueError(f"{query_id}: cada resultado precisa de source_id") + if not isinstance(item.get("project_id"), str) or not item["project_id"]: + raise ValueError(f"{query_id}: cada resultado precisa de project_id para validar isolamento") + if item["source_id"] in seen: + raise ValueError(f"{query_id}: source_id duplicado nos resultados") + 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") + 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") + if item["source_id"] in dataset["source_project_ids"] and dataset["source_project_ids"][item["source_id"]] != query_project_id: + raise ValueError(f"{query_id}: fonte conhecida pertence a outro projeto") + actual_by_id[query_id] = run + + missing = sorted(set(expected_by_id) - set(actual_by_id)) + if missing: + raise ValueError("faltam resultados para: " + ", ".join(missing)) + + answerable = [] + no_result = [] + latencies: list[float] = [] + within_budget = 0 + isolation_checks = 0 + isolation_violations = 0 + per_query = [] + category_metrics: dict[str, dict[str, list[Any]]] = {} + for query_id, query in expected_by_id.items(): + run = actual_by_id[query_id] + ranked = run["results"][:k] + returned_ids = [item["source_id"] for item in ranked] + expected_ids = set(query["expected_source_ids"]) + 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 + category = query.get("category", "uncategorized") + category_bucket = category_metrics.setdefault(category, { + "answerable": [], "no_result": [], "latencies": [], + }) + category_bucket["latencies"].append(latency) + + if expected_ids: + recall = len(set(returned_ids) & expected_ids) / len(expected_ids) + precision = len(set(returned_ids) & expected_ids) / k + reciprocal_rank = 1 / relevant_positions[0] if relevant_positions else 0.0 + answerable.append((recall, precision, reciprocal_rank)) + category_bucket["answerable"].append((recall, precision, reciprocal_rank)) + else: + recall = None + precision = None + reciprocal_rank = None + no_result.append(not returned_ids) + category_bucket["no_result"].append(not returned_ids) + + project_id = query.get("project_id") + isolation_checks += 1 + for item in run["results"]: + returned_project_id = item["project_id"] + corpus_project_id = dataset["source_project_ids"].get(item["source_id"]) + if returned_project_id != project_id or (corpus_project_id is not None and returned_project_id != corpus_project_id): + isolation_violations += 1 + + per_query.append({ + "query_id": query_id, + "category": category, + "filters": query.get("filters", {}), + "expected_source_ids": sorted(expected_ids), + "returned_source_ids_at_k": returned_ids, + "returned_results_at_k": [ + {"rank": index + 1, "source_id": item["source_id"], + "relevance_score": item.get("relevance_score")} + for index, item in enumerate(ranked) + ], + "recall_at_k": recall, + "precision_at_k": precision, + "reciprocal_rank_at_k": reciprocal_rank, + "latency_ms": latency, + "within_latency_budget": latency <= latency_budget_ms, + }) + + by_category = {} + for category, values in sorted(category_metrics.items()): + answerable_category = values["answerable"] + no_result_category = values["no_result"] + category_latencies = values["latencies"] + by_category[category] = { + "queries": len(category_latencies), + "answerable_queries": len(answerable_category), + "recall_at_k_macro": sum(item[0] for item in answerable_category) / len(answerable_category) if answerable_category else None, + "precision_at_k_macro": sum(item[1] for item in answerable_category) / len(answerable_category) if answerable_category else None, + "mrr_at_k": sum(item[2] for item in answerable_category) / len(answerable_category) if answerable_category else None, + "no_result_queries": len(no_result_category), + "no_result_accuracy": sum(no_result_category) / len(no_result_category) if no_result_category else None, + "latency_p50_ms": _percentile(category_latencies, 0.50), + "latency_p95_ms": _percentile(category_latencies, 0.95), + } + + return { + "dataset": dataset["dataset"], + "version": dataset["version"], + "queries": len(expected_by_id), + "k": k, + "latency_budget_ms": latency_budget_ms, + "retrieval": { + "answerable_queries": len(answerable), + "recall_at_k_macro": sum(x[0] for x in answerable) / len(answerable) if answerable else None, + "precision_at_k_macro": sum(x[1] for x in answerable) / len(answerable) if answerable else None, + "mrr_at_k": sum(x[2] for x in answerable) / len(answerable) if answerable else None, + "no_result_queries": len(no_result), + "no_result_accuracy": sum(no_result) / len(no_result) if no_result else None, + }, + "latency": { + "p50_ms": _percentile(latencies, 0.50), + "p95_ms": _percentile(latencies, 0.95), + "within_budget_count": within_budget, + "within_budget_rate": within_budget / len(latencies), + }, + "project_isolation": { + "scoped_queries": isolation_checks, + "violations": isolation_violations, + }, + "by_category": by_category, + "per_query": per_query, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dataset", type=Path, default=DEFAULT_DATASET) + parser.add_argument("--results", type=Path, required=True, help="JSON normalizado produzido pelo adaptador da busca") + parser.add_argument("--k", type=int, default=5) + 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) + except (OSError, json.JSONDecodeError, ValueError) as error: + print(f"Erro na avaliação: {error}", file=sys.stderr) + return 2 + json.dump(report, sys.stdout, ensure_ascii=False, indent=2) + print() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ai-service/evaluation/run_search_suite.py b/ai-service/evaluation/run_search_suite.py new file mode 100644 index 0000000..8101c49 --- /dev/null +++ b/ai-service/evaluation/run_search_suite.py @@ -0,0 +1,209 @@ +"""Run the versioned query set against the authenticated Sinapse search API.""" + +from __future__ import annotations + +import argparse +import hashlib +import ipaddress +import json +import os +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urlsplit +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from evaluate_search import DEFAULT_DATASET, load_json, validate_dataset + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] + + +def validate_api_base_url(value: str) -> str: + """Refuse to send the local session cookie anywhere except loopback.""" + try: + parsed = urlsplit(value) + hostname = (parsed.hostname or "").lower() + is_loopback = hostname == "localhost" or ipaddress.ip_address(hostname).is_loopback + _ = parsed.port + except (ValueError, TypeError): + raise ValueError("A API de avaliação deve ser HTTP local (localhost/loopback), sem credenciais ou caminho") from None + if (not is_loopback or parsed.scheme != "http" or parsed.username or parsed.password + or parsed.path not in ("", "/") or parsed.query or parsed.fragment): + raise ValueError("A API de avaliação deve ser HTTP local (localhost/loopback), sem credenciais ou caminho") + return value.rstrip("/") + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as file: + for block in iter(lambda: file.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def source_snapshot_sha256(root: Path = REPOSITORY_ROOT) -> str: + """Hash tracked and non-ignored working files, excluding local env/temp data.""" + result = subprocess.run( + ["git", "ls-files", "-co", "--exclude-standard", "-z"], + cwd=root, check=True, capture_output=True, + ) + paths = sorted(path for path in result.stdout.decode("utf-8", errors="strict").split("\0") if path) + digest = hashlib.sha256() + for relative in paths: + normalized = relative.replace("\\", "/") + parts = normalized.split("/") + if normalized == "tmp" or normalized.startswith("tmp/") or "node_modules" in parts: + continue + if any(Path(part).name.lower().startswith(".env") for part in parts): + continue + path = root / relative + if path.is_symlink() or not path.is_file(): + continue + digest.update(normalized.encode("utf-8")) + digest.update(b"\0") + with path.open("rb") as file: + for block in iter(lambda: file.read(1024 * 1024), b""): + digest.update(block) + digest.update(b"\0") + return digest.hexdigest() + + +def working_tree_is_dirty(root: Path = REPOSITORY_ROOT) -> bool: + result = subprocess.run( + ["git", "status", "--porcelain", "--untracked-files=normal"], + cwd=root, check=True, capture_output=True, text=True, + ) + return bool(result.stdout.strip()) + + +def run_query(api_base_url: str, session_cookie: str, query: dict, limit: int, timeout: float) -> dict: + parameters = { + "q": query["query"], + "projeto_id": query["project_id"], + "limit": limit, + } + filters = query.get("filters", {}) + if filters.get("technology_id"): + parameters["tecnologia_id"] = filters["technology_id"] + if filters.get("level"): + parameters["nivel"] = filters["level"] + url = f"{api_base_url.rstrip('/')}/api/v1/search?{urlencode(parameters)}" + request = Request(url, headers={ + "Accept": "application/json", + "Cookie": f"sinapse_session={session_cookie}", + }) + started = time.perf_counter() + try: + with urlopen(request, timeout=timeout) as response: + payload = json.load(response) + except HTTPError as error: + raise RuntimeError(f"{query['id']}: busca respondeu HTTP {error.code}") from None + except (URLError, TimeoutError, json.JSONDecodeError, OSError) as error: + raise RuntimeError(f"{query['id']}: não foi possível completar a consulta ({type(error).__name__})") from None + latency_ms = round((time.perf_counter() - started) * 1000) + items = payload.get("items") if isinstance(payload, dict) else None + if not isinstance(items, list): + raise RuntimeError(f"{query['id']}: resposta da busca não contém items[]") + normalized = [] + for item in items: + 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"]} + score = item.get("relevance_score") + if isinstance(score, (int, float)) and not isinstance(score, bool): + normalized_item["relevance_score"] = float(score) + normalized.append(normalized_item) + return {"query_id": query["id"], "latency_ms": latency_ms, "results": normalized} + + +def run_suite(dataset: dict, api_base_url: str, session_cookie: str, limit: int = 5, timeout: float = 30, + dataset_path: Path | None = None) -> dict: + validate_dataset(dataset) + 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: + raise ValueError("limit deve estar entre 1 e 50") + runs = [run_query(api_base_url, session_cookie, query, limit, timeout) for query in dataset["queries"]] + run_by_id = {run["query_id"]: run for run in runs} + categories = sorted({query.get("category", "uncategorized") for query in dataset["queries"]}) + by_category = { + category: [run_by_id[query["id"]]["latency_ms"] for query in dataset["queries"] + if query.get("category", "uncategorized") == category] + for category in categories + } + ordered_latency = sorted(run["latency_ms"] for run in runs) + try: + revision = os.getenv("SEARCH_CODE_REVISION") or subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=REPOSITORY_ROOT, check=True, capture_output=True, text=True, + ).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" + corpus_path = REPOSITORY_ROOT / dataset.get("corpus", {}).get("file", "") + try: + dirty = working_tree_is_dirty() + snapshot_hash = source_snapshot_sha256() + except (OSError, subprocess.CalledProcessError, UnicodeDecodeError): + dirty, snapshot_hash = None, "unavailable" + try: + corpus_hash = _sha256_file(corpus_path) if corpus_path.is_file() else "unavailable" + except OSError: + corpus_hash = "unavailable" + def percentile(p: float) -> int: + import math + return ordered_latency[max(0, math.ceil(p * len(ordered_latency)) - 1)] + return { + "dataset": dataset["dataset"], + "version": dataset["version"], + "started_at": datetime.now(timezone.utc).isoformat(), + "limit": limit, + "execution_metadata": { + "query_count": len(runs), + "categories": {category: len(values) for category, values in by_category.items()}, + "latency_p50_ms": percentile(0.50), + "latency_p95_ms": percentile(0.95), + "endpoint_kind": "authenticated_local_api", + "code_revision": revision, + "working_tree_dirty": dirty, + "source_snapshot_sha256": snapshot_hash, + "dataset_sha256": _sha256_file(dataset_file), + "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")), + "minimum_text_rank": float(os.getenv("SEARCH_MIN_TEXT_RANK", "0.05")), + "ollama_warmed_up": os.getenv("SEARCH_OLLAMA_WARMED_UP", "unknown"), + }, + "runs": runs, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dataset", type=Path, default=DEFAULT_DATASET) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--api-base-url", default=os.getenv("SINAPSE_API_BASE_URL", "http://localhost:3001")) + parser.add_argument("--limit", type=int, default=5) + parser.add_argument("--timeout", type=float, default=30) + args = parser.parse_args(argv) + try: + dataset = load_json(args.dataset) + result = run_suite(dataset, args.api_base_url, os.getenv("SINAPSE_SESSION_COOKIE", ""), args.limit, args.timeout, args.dataset) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + except (OSError, json.JSONDecodeError, ValueError, RuntimeError) as error: + print(f"Erro ao executar a bateria: {error}", file=sys.stderr) + return 2 + print(f"Resultados da avaliação gravados em {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ai-service/main.py b/ai-service/main.py index c6c028e..0a2be85 100644 --- a/ai-service/main.py +++ b/ai-service/main.py @@ -1,9 +1,19 @@ from contextlib import asynccontextmanager +import base64 +import binascii +import hmac +import io +from pathlib import PurePath +import zipfile from typing import Any, Literal -from fastapi import FastAPI, HTTPException, status +from fastapi import FastAPI, Header, HTTPException, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import PlainTextResponse from pydantic import BaseModel, Field +from docx import Document as WordDocument +from docx.table import Table as WordTable +from docx.text.paragraph import Paragraph as WordParagraph +from pypdf import PdfReader from config import settings from services.ollama_client import ollama_client @@ -53,6 +63,31 @@ class EmbeddingRequest(BaseModel): model: str | None = Field(None, description="Modelo de embedding (padrão do settings)") +class ProcessDocumentRequest(BaseModel): + document_id: str = Field(..., min_length=36, max_length=36) + project_id: str = Field(..., min_length=36, max_length=36) + filename: str = Field(..., min_length=1, max_length=255) + content_base64: str = Field(..., min_length=1, max_length=28_000_000) + + +def extract_docx_text(content: bytes) -> str: + """Extract body paragraphs and tables in document order (python-docx omits tables from paragraphs).""" + document = WordDocument(io.BytesIO(content)) + parts: list[str] = [] + for element in document.element.body.iterchildren(): + if element.tag.endswith("}p"): + paragraph = WordParagraph(element, document) + if paragraph.text.strip(): + parts.append(paragraph.text) + elif element.tag.endswith("}tbl"): + table = WordTable(element, document) + for row in table.rows: + cells = [cell.text.strip() for cell in row.cells] + if any(cells): + parts.append(" | ".join(cells)) + return "\n\n".join(parts) + + class RagQueryRequest(BaseModel): query: str = Field(..., description="Pergunta ou busca em linguagem natural") project_id: str | None = Field(None, description="Filtro obrigatório de projeto (PRD 10.3)") @@ -145,6 +180,83 @@ async def generate_embedding(req: EmbeddingRequest): ) +@app.post("/documents/process") +async def process_document(req: ProcessDocumentRequest, internal_token: str | None = Header(None, alias="X-Document-Ingestion-Token")): + """Extrai, fragmenta e vetoriza; somente o backend persiste no banco.""" + expected_token = settings.DOCUMENT_INGESTION_TOKEN + if len(expected_token) < 32: + raise HTTPException(status_code=503, detail="A autenticação interna da ingestão não está configurada.") + if not internal_token or not hmac.compare_digest(internal_token, expected_token): + raise HTTPException(status_code=401, detail="Não autorizado.") + extension = PurePath(req.filename).suffix.lower() + try: + content = base64.b64decode(req.content_base64, validate=True) + except (binascii.Error, ValueError): + raise HTTPException(status_code=400, detail="Conteúdo do documento inválido.") from None + if not content or len(content) > 20 * 1024 * 1024: + raise HTTPException(status_code=413, detail="Documento vazio ou acima do limite suportado.") + try: + if extension == ".pdf": + reader = PdfReader(io.BytesIO(content), strict=True) + if reader.is_encrypted: + raise ValueError("encrypted PDF") + if len(reader.pages) > 5000: + raise HTTPException(status_code=413, detail="O PDF excede o limite de páginas suportado.") + text_parts = [] + total_chars = 0 + for page in reader.pages: + page_text = page.extract_text() or "" + total_chars += len(page_text) + if total_chars > 2_000_000: + raise HTTPException(status_code=413, detail="O texto extraído excede o limite de indexação.") + text_parts.append(page_text) + text = "\n\n".join(text_parts) + elif extension == ".docx": + with zipfile.ZipFile(io.BytesIO(content)) as archive: + entries = archive.infolist() + if len(entries) > 5000 or sum(item.file_size for item in entries) > 100_000_000: + raise HTTPException(status_code=413, detail="O DOCX excede os limites de conteúdo descompactado.") + if any(item.file_size > max(item.compress_size, 1) * 100 for item in entries): + raise HTTPException(status_code=413, detail="O DOCX contém dados excessivamente comprimidos.") + text = extract_docx_text(content) + if len(text) > 2_000_000: + raise HTTPException(status_code=413, detail="O texto extraído excede o limite de indexação.") + elif extension in {".md", ".txt"}: + text = content.decode("utf-8-sig", errors="strict") + if len(text) > 2_000_000: + raise HTTPException(status_code=413, detail="O texto extraído excede o limite de indexação.") + else: + raise ValueError("unsupported extension") + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=422, detail="Não foi possível extrair texto válido do documento.") from None + chunks = chunk_document_text(text) + if not chunks: + raise HTTPException(status_code=422, detail="O documento não contém texto extraível para indexação.") + if len(chunks) > 500: + raise HTTPException(status_code=413, detail="O documento excede o limite de 500 trechos indexáveis.") + processed = [] + try: + for index, chunk in enumerate(chunks): + embedding = await ollama_client.get_embedding(chunk) + if len(embedding) != 1024: + raise ValueError("invalid embedding dimension") + processed.append({ + "chunk_index": index, + "text": chunk, + "embedding": embedding, + "metadata": { + "project_id": req.project_id, + "document_id": req.document_id, + "source_name": req.filename, + }, + }) + except Exception: + raise HTTPException(status_code=503, detail="Não foi possível gerar os embeddings locais.") from None + return {"document_id": req.document_id, "project_id": req.project_id, "chunks": processed} + + @app.post("/rag/query") async def query_rag(req: RagQueryRequest): """ diff --git a/ai-service/requirements.txt b/ai-service/requirements.txt index 17c68bb..b7b0e21 100644 --- a/ai-service/requirements.txt +++ b/ai-service/requirements.txt @@ -4,3 +4,5 @@ httpx>=0.28.0 pydantic>=2.10.0 pydantic-settings>=2.7.0 python-dotenv>=1.0.1 +pypdf>=5.1.0 +python-docx>=1.1.2 diff --git a/ai-service/services/chunker.py b/ai-service/services/chunker.py index 2b4432b..5ed75a5 100644 --- a/ai-service/services/chunker.py +++ b/ai-service/services/chunker.py @@ -11,40 +11,43 @@ def chunk_document_text(text: str, chunk_size: int = 1000, overlap: int = 150) - if not text or not text.strip(): return [] - # Normalize newlines - text = text.replace("\r\n", "\n") + if chunk_size < 1 or overlap < 0 or overlap >= chunk_size: + raise ValueError("chunk_size deve ser positivo e overlap menor que chunk_size") + text = text.replace("\r\n", "\n").replace("\r", "\n") paragraphs = re.split(r"\n\s*\n", text) - chunks: list[str] = [] - current_chunk: list[str] = [] - current_len = 0 - + current = "" for para in paragraphs: para = para.strip() if not para: continue - - para_len = len(para) - - if current_len + para_len <= chunk_size: - current_chunk.append(para) - current_len += para_len + 1 - else: - if current_chunk: - chunk_str = "\n\n".join(current_chunk) - chunks.append(chunk_str) - # Keep overlap if possible - overlap_text = chunk_str[-overlap:] if len(chunk_str) > overlap else "" - current_chunk = [overlap_text, para] if overlap_text else [para] - current_len = sum(len(p) for p in current_chunk) + len(current_chunk) - 1 - else: - # Single paragraph exceeds chunk_size, break by sentence or length - chunks.append(para[:chunk_size]) - current_chunk = [para[chunk_size - overlap:]] - current_len = len(current_chunk[0]) + # Long paragraphs are split without dropping their tail. Prefer whitespace + # boundaries, while guaranteeing forward progress for unbroken tokens. + pieces: list[str] = [] + remaining = para + while len(remaining) > chunk_size: + cut = remaining.rfind(" ", 0, chunk_size + 1) + if cut <= 0: + cut = chunk_size + pieces.append(remaining[:cut].strip()) + remaining = remaining[max(1, cut - overlap):].lstrip() + if remaining: + pieces.append(remaining) - if current_chunk: - chunks.append("\n\n".join(current_chunk)) + for piece in pieces: + candidate = f"{current}\n\n{piece}" if current else piece + if len(candidate) <= chunk_size: + current = candidate + else: + if current: + chunks.append(current) + carry = current[-overlap:].strip() if current and overlap else "" + current = f"{carry}\n{piece}" if carry else piece + if len(current) > chunk_size: + chunks.append(current[:chunk_size]) + current = current[chunk_size - overlap:] + if current: + chunks.append(current) return [c.strip() for c in chunks if c.strip()] diff --git a/ai-service/tests/test_chunker.py b/ai-service/tests/test_chunker.py index 37a1f6b..5bb62d7 100644 --- a/ai-service/tests/test_chunker.py +++ b/ai-service/tests/test_chunker.py @@ -14,6 +14,21 @@ def test_preserves_paragraph_boundaries_and_overlap(self): self.assertIn("Primeiro parágrafo.", chunks[0]) self.assertTrue(all(chunk.strip() for chunk in chunks)) + def test_long_paragraph_is_fully_preserved_without_oversized_chunks(self): + text = " ".join(f"token-{index}" for index in range(500)) + chunks = chunk_document_text(text, chunk_size=100, overlap=15) + self.assertGreater(len(chunks), 2) + self.assertTrue(all(len(chunk) <= 100 for chunk in chunks)) + for index in (0, 100, 250, 499): + token = f"token-{index}" + self.assertTrue(any(token in chunk for chunk in chunks), token) + + def test_invalid_chunk_sizes_are_rejected(self): + with self.assertRaises(ValueError): + chunk_document_text("text", chunk_size=0) + with self.assertRaises(ValueError): + chunk_document_text("text", chunk_size=10, overlap=10) + def test_structured_chunk_keeps_project_metadata_and_provenance(self): result = create_structured_chunk( "pbi", diff --git a/ai-service/tests/test_document_ingestion.py b/ai-service/tests/test_document_ingestion.py new file mode 100644 index 0000000..8725660 --- /dev/null +++ b/ai-service/tests/test_document_ingestion.py @@ -0,0 +1,218 @@ +import asyncio +import base64 +import sys +import unittest +import io +from pathlib import Path +from urllib.parse import unquote +from unittest.mock import patch + +AI_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(AI_ROOT)) +sys.path.insert(0, str(AI_ROOT.parent / "scripts")) + +from fastapi import HTTPException # noqa: E402 +from main import ProcessDocumentRequest, process_document # noqa: E402 +from docx import Document as WordDocument # noqa: E402 +from config import Settings # noqa: E402 +import smoke_document_lifecycle # noqa: E402 +from smoke_document_lifecycle import docx_bytes, pdf_bytes, validate_api_base # noqa: E402 + +TEST_INGESTION_TOKEN = "test-only-document-ingestion-token-0123456789abcdef" + + +class DocumentIngestionTests(unittest.TestCase): + def setUp(self): + self.token_patch = patch("main.settings.DOCUMENT_INGESTION_TOKEN", TEST_INGESTION_TOKEN) + self.token_patch.start() + + def tearDown(self): + self.token_patch.stop() + + def test_smoke_never_sends_session_cookie_to_nonlocal_api(self): + for remote in ("https://localhost:3001", "http://example.com", "http://localhost.evil.test", "http://user:pass@localhost:3001"): + with self.subTest(api=remote), self.assertRaises(ValueError): + validate_api_base(remote) + self.assertEqual(validate_api_base("http://127.0.0.1:3001/"), "http://127.0.0.1:3001") + + def test_production_settings_reject_a_missing_secret(self): + from pydantic import ValidationError + with self.assertRaises(ValidationError): + Settings(NODE_ENV="production", DOCUMENT_INGESTION_TOKEN="") + + def test_production_settings_accept_a_private_strong_token(self): + settings = Settings(NODE_ENV="production", DOCUMENT_INGESTION_TOKEN=TEST_INGESTION_TOKEN) + self.assertEqual(settings.DOCUMENT_INGESTION_TOKEN, TEST_INGESTION_TOKEN) + + def test_extracts_text_and_returns_project_scoped_vectors_without_persisting(self): + request = ProcessDocumentRequest( + document_id="60000000-0000-4000-8000-000000000001", + project_id="60000000-0000-4000-8000-000000000002", + filename="notes.txt", + content_base64=base64.b64encode(b"Manual de acesso\n\nAutenticacao local").decode(), + ) + async def embedding(text): + self.assertTrue(text.strip()) + return [0.1] * 1024 + with patch("main.ollama_client.get_embedding", side_effect=embedding): + result = asyncio.run(process_document(request, TEST_INGESTION_TOKEN)) + self.assertEqual(result["project_id"], request.project_id) + self.assertEqual(len(result["chunks"]), 1) + self.assertEqual(result["chunks"][0]["metadata"]["document_id"], request.document_id) + self.assertEqual(len(result["chunks"][0]["embedding"]), 1024) + + def test_synthetic_smoke_fixtures_are_extractable_in_all_supported_formats(self): + marker = "Quasar Nectario 7f6c-smoke-fixture" + fixtures = ( + ("fixture.pdf", "application/pdf", pdf_bytes(marker)), + ("fixture.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", docx_bytes(marker)), + ("fixture.md", "text/markdown", f"# Smoke\n\n{marker}\n".encode()), + ("fixture.txt", "text/plain", f"Smoke documental.\n\n{marker}\n".encode()), + ) + + async def embedding(_text): + return [0.1] * 1024 + + for filename, _mime, content in fixtures: + request = ProcessDocumentRequest( + document_id="60000000-0000-4000-8000-000000000001", + project_id="60000000-0000-4000-8000-000000000002", + filename=filename, + content_base64=base64.b64encode(content).decode(), + ) + with self.subTest(filename=filename), patch("main.ollama_client.get_embedding", side_effect=embedding): + result = asyncio.run(process_document(request, TEST_INGESTION_TOKEN)) + self.assertGreaterEqual(len(result["chunks"]), 1) + self.assertIn(marker, "\n".join(chunk["text"] for chunk in result["chunks"])) + self.assertTrue(all(len(chunk["embedding"]) == 1024 for chunk in result["chunks"])) + + def test_lifecycle_smoke_uploads_searches_and_removes_all_four_formats(self): + project_id = "60000000-0000-4000-8000-000000000002" + active = {} + removed = set() + + def fake_request(url, _cookie, method="GET", body=None, headers=None): + if method == "POST": + document_id = f"60000000-0000-4000-8000-{len(active) + len(removed) + 1:012d}" + filename = unquote(headers["X-File-Name"]) + active[document_id] = filename + return 201, {"id": document_id, "nome": filename, "projeto_id": project_id} + if method == "DELETE": + document_id = url.rsplit("/", 1)[-1] + active.pop(document_id, None) + removed.add(document_id) + return 204, None + if "/documents?" in url: + return 200, {"items": [ + {"id": document_id, "projeto_id": project_id, "nome": filename, "status_processamento": "processado"} + for document_id, filename in active.items() + ]} + if "/search?" in url: + return 200, {"items": [ + {"entity_id": document_id, "project_id": project_id, "source_url": f"/projects/{project_id}/documents"} + for document_id in active if document_id not in removed + ]} + raise AssertionError(f"Rota inesperada no teste: {method} {url.split('?')[0]}") + + with patch.object(smoke_document_lifecycle, "request_json", side_effect=fake_request), patch("builtins.print"): + smoke_document_lifecycle.run("http://127.0.0.1", project_id, "session-cookie-not-printed", 5) + + self.assertEqual(len(removed), 4) + self.assertFalse(active) + + def test_docx_ingestion_extracts_paragraphs_and_table_cells_in_order(self): + document = WordDocument() + document.add_paragraph("Resumo do projeto") + table = document.add_table(rows=2, cols=2) + table.cell(0, 0).text = "Stack" + table.cell(0, 1).text = "React" + table.cell(1, 0).text = "Backend" + table.cell(1, 1).text = "Node.js" + document.add_paragraph("Decisão final") + content = io.BytesIO() + document.save(content) + request = ProcessDocumentRequest( + document_id="60000000-0000-4000-8000-000000000001", + project_id="60000000-0000-4000-8000-000000000002", + filename="architecture.docx", + content_base64=base64.b64encode(content.getvalue()).decode(), + ) + async def embedding(_text): + return [0.1] * 1024 + with patch("main.ollama_client.get_embedding", side_effect=embedding): + result = asyncio.run(process_document(request, TEST_INGESTION_TOKEN)) + extracted = result["chunks"][0]["text"] + self.assertLess(extracted.index("Resumo do projeto"), extracted.index("Stack | React")) + self.assertLess(extracted.index("Stack | React"), extracted.index("Backend | Node.js")) + self.assertLess(extracted.index("Backend | Node.js"), extracted.index("Decisão final")) + + def test_rejects_empty_or_invalid_utf8_text(self): + for content in (b" ", b"\xff\xfe\xfa"): + request = ProcessDocumentRequest( + document_id="60000000-0000-4000-8000-000000000001", + project_id="60000000-0000-4000-8000-000000000002", + filename="notes.txt", + content_base64=base64.b64encode(content).decode(), + ) + with self.subTest(content=content), self.assertRaises(HTTPException) as raised: + asyncio.run(process_document(request, TEST_INGESTION_TOKEN)) + self.assertEqual(raised.exception.status_code, 422) + + def test_rejects_more_than_500_chunks_before_calling_ollama(self): + request = ProcessDocumentRequest( + document_id="60000000-0000-4000-8000-000000000001", + project_id="60000000-0000-4000-8000-000000000002", + filename="large.txt", + content_base64=base64.b64encode(b"x" * 501_000).decode(), + ) + with patch("main.ollama_client.get_embedding") as embedding: + with self.assertRaises(HTTPException) as raised: + asyncio.run(process_document(request, TEST_INGESTION_TOKEN)) + self.assertEqual(raised.exception.status_code, 413) + embedding.assert_not_called() + + def test_embedding_failure_does_not_return_partial_chunks(self): + request = ProcessDocumentRequest( + document_id="60000000-0000-4000-8000-000000000001", + project_id="60000000-0000-4000-8000-000000000002", + filename="notes.txt", + content_base64=base64.b64encode(b"Manual de acesso").decode(), + ) + async def unavailable(_text): + raise RuntimeError("private ollama detail") + with patch("main.ollama_client.get_embedding", side_effect=unavailable): + with self.assertRaises(HTTPException) as raised: + asyncio.run(process_document(request, TEST_INGESTION_TOKEN)) + self.assertEqual(raised.exception.status_code, 503) + self.assertNotIn("private", raised.exception.detail) + + def test_rejects_request_without_valid_internal_token_before_ollama(self): + request = ProcessDocumentRequest( + document_id="60000000-0000-4000-8000-000000000001", + project_id="60000000-0000-4000-8000-000000000002", + filename="notes.txt", + content_base64=base64.b64encode(b"content").decode(), + ) + with patch("main.ollama_client.get_embedding") as embedding: + with self.assertRaises(HTTPException) as raised: + asyncio.run(process_document(request, "wrong-secret")) + self.assertEqual(raised.exception.status_code, 401) + embedding.assert_not_called() + + def test_rejects_ingestion_when_service_secret_is_not_configured(self): + request = ProcessDocumentRequest( + document_id="60000000-0000-4000-8000-000000000001", + project_id="60000000-0000-4000-8000-000000000002", + filename="notes.txt", + content_base64=base64.b64encode(b"content").decode(), + ) + with patch("main.settings.DOCUMENT_INGESTION_TOKEN", ""): + with patch("main.ollama_client.get_embedding") as embedding: + with self.assertRaises(HTTPException) as raised: + asyncio.run(process_document(request, TEST_INGESTION_TOKEN)) + self.assertEqual(raised.exception.status_code, 503) + embedding.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/ai-service/tests/test_search_evaluation.py b/ai-service/tests/test_search_evaluation.py new file mode 100644 index 0000000..85a7344 --- /dev/null +++ b/ai-service/tests/test_search_evaluation.py @@ -0,0 +1,220 @@ +import json +import sys +import unittest +from pathlib import Path + +EVALUATION_DIR = Path(__file__).resolve().parents[1] / "evaluation" +sys.path.insert(0, str(EVALUATION_DIR)) + +from evaluate_search import DEFAULT_DATASET, evaluate, load_json, validate_dataset # noqa: E402 + + +class SearchEvaluationTests(unittest.TestCase): + def setUp(self): + self.dataset = load_json(DEFAULT_DATASET) + + def _runs(self): + runs = [] + for query in self.dataset["queries"]: + ids = query["expected_source_ids"] + runs.append({ + "query_id": query["id"], + "latency_ms": 100, + "results": [ + {"source_id": source_id, "project_id": self.dataset["source_project_ids"][source_id]} + for source_id in ids + ], + }) + return {"dataset": self.dataset["dataset"], "version": self.dataset["version"], "runs": runs} + + def test_dataset_has_24_unique_queries_and_valid_source_ids(self): + validate_dataset(self.dataset) + self.assertEqual(len(self.dataset["queries"]), 24) + self.assertEqual(len({query["id"] for query in self.dataset["queries"]}), 24) + + def test_dataset_rejects_duplicate_ids_and_missing_query_text(self): + duplicate = json.loads(json.dumps(self.dataset)) + duplicate["queries"][1]["id"] = duplicate["queries"][0]["id"] + with self.assertRaisesRegex(ValueError, "id único"): + validate_dataset(duplicate) + missing_text = json.loads(json.dumps(self.dataset)) + missing_text["queries"][0]["query"] = " " + with self.assertRaisesRegex(ValueError, "consulta vazia"): + validate_dataset(missing_text) + + def test_dataset_source_expectations_reference_the_approved_pre06_fixture(self): + fixture = load_json(Path(__file__).resolve().parents[2] / "database/seed/fixtures/historical-v1.json") + valid_ids = {record["values"]["id"] for record in fixture["records"] if record["table"] == "chunk"} + expected_ids = { + source_id + for query in self.dataset["queries"] + for source_id in query["expected_source_ids"] + } + source_projects = { + record["values"]["id"]: record["values"]["projeto_id"] + for record in fixture["records"] + if record["table"] == "chunk" + } + self.assertTrue(expected_ids) + self.assertLessEqual(expected_ids, valid_ids) + self.assertEqual(self.dataset["source_project_ids"], source_projects) + source_types = { + record["values"]["id"]: record["values"]["entidade_tipo"] + for record in fixture["records"] + if record["table"] == "chunk" + } + self.assertEqual(self.dataset["source_entity_types"], source_types) + + def test_v2_preserves_identifier_queries_and_adds_content_queries_without_mutating_v1(self): + v2_path = EVALUATION_DIR / "datasets" / "search-ptbr-v2.json" + v2 = load_json(v2_path) + fixture = load_json(Path(__file__).resolve().parents[2] / "database/seed/fixtures/historical-v2.json") + validate_dataset(v2) + self.assertEqual(v2["version"], 2) + self.assertEqual(len(v2["queries"]), len(self.dataset["queries"]) + 2) + normalized_v2_queries = json.loads(json.dumps(v2["queries"][:24])) + for query in normalized_v2_queries: + query["project_id"] = query["project_id"].replace("62000000", "60000000", 1) + query["expected_source_ids"] = [source_id.replace("62000000", "60000000", 1) for source_id in query["expected_source_ids"]] + self.assertEqual(normalized_v2_queries, self.dataset["queries"]) + self.assertEqual({q["query"] for q in v2["queries"] if q["category"] == "exact_identifier"} & {"GRF-01", "GRF-08"}, {"GRF-01", "GRF-08"}) + self.assertEqual(sum(q["category"] == "content_search" for q in v2["queries"]), 2) + locators = { + record["values"]["metadados_json"].get("source_locator") + for record in fixture["records"] if record["table"] == "chunk" + } + self.assertTrue({"GRF-01", "GRF-08"} <= locators) + + def test_retrieval_with_all_relevant_sources_first_has_full_recall_and_mrr(self): + report = evaluate(self.dataset, self._runs()) + self.assertEqual(report["retrieval"]["recall_at_k_macro"], 1) + positive_queries = [query for query in self.dataset["queries"] if query["expected_source_ids"]] + expected_precision = sum(min(len(query["expected_source_ids"]), 5) / 5 for query in positive_queries) / len(positive_queries) + self.assertAlmostEqual(report["retrieval"]["precision_at_k_macro"], expected_precision) + 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"], 100) + + def test_report_aggregates_retrieval_absence_and_latency_by_category(self): + report = evaluate(self.dataset, self._runs()) + self.assertIn("exact_identifier", report["by_category"]) + self.assertIn("semantic_paraphrase", report["by_category"]) + exact = report["by_category"]["exact_identifier"] + self.assertGreater(exact["queries"], 0) + self.assertEqual(exact["recall_at_k_macro"], 1) + self.assertEqual(exact["latency_p95_ms"], 100) + no_result = report["by_category"]["no_relevant_result"] + self.assertEqual(no_result["no_result_accuracy"], 1) + + def test_isolation_violation_is_rejected(self): + results = self._runs() + query = next(q for q in self.dataset["queries"] if q["id"] == "Q002") + run = next(r for r in results["runs"] if r["query_id"] == query["id"]) + run["results"].append({ + "source_id": "60000000-0000-4000-8000-000000000008", + "project_id": "60000000-0000-4000-8000-000000000006", + }) + with self.assertRaisesRegex(ValueError, "fora do projeto solicitado"): + evaluate(self.dataset, results) + + def test_no_result_queries_return_accuracy_zero_when_any_result_leaks_in(self): + results = self._runs() + run = next(r for r in results["runs"] if r["query_id"] == "Q017") + run["results"].append({ + "source_id": "60000000-0000-4000-8000-000000000003", + "project_id": "60000000-0000-4000-8000-000000000001", + }) + with self.assertRaisesRegex(ValueError, "fora do projeto solicitado"): + evaluate(self.dataset, results) + + def test_missing_duplicate_and_unknown_query_results_are_rejected(self): + results = self._runs() + results["runs"].pop() + with self.assertRaisesRegex(ValueError, "faltam resultados"): + evaluate(self.dataset, results) + results = self._runs() + results["runs"].append(results["runs"][0]) + with self.assertRaisesRegex(ValueError, "duplicado"): + evaluate(self.dataset, results) + results = self._runs() + results["runs"][0]["query_id"] = "Q999" + with self.assertRaisesRegex(ValueError, "desconhecido"): + evaluate(self.dataset, results) + + def test_result_dataset_version_and_duplicate_source_identity_are_rejected(self): + results = self._runs() + results["version"] += 1 + with self.assertRaisesRegex(ValueError, "versão dos resultados"): + evaluate(self.dataset, results) + results = self._runs() + run = next(r for r in results["runs"] if r["query_id"] == "Q002") + run["results"].append(dict(run["results"][0])) + with self.assertRaisesRegex(ValueError, "source_id duplicado"): + evaluate(self.dataset, results) + + def test_unknown_result_source_is_counted_as_noise_but_scoped(self): + results = self._runs() + run = next(r for r in results["runs"] if r["query_id"] == "Q002") + run["results"].append({ + "source_id": "unknown-source-id", + "project_id": run["results"][0]["project_id"], + }) + report = evaluate(self.dataset, results) + self.assertEqual(report["retrieval"]["recall_at_k_macro"], 1) + self.assertEqual(report["project_isolation"]["violations"], 0) + query_report = next(item for item in report["per_query"] if item["query_id"] == "Q002") + self.assertIn("unknown-source-id", query_report["returned_source_ids_at_k"]) + + def test_isolation_uses_the_returned_project_and_known_corpus_ownership(self): + results = self._runs() + run = next(r for r in results["runs"] if r["query_id"] == "Q002") + run["results"][0]["project_id"] = "60000000-0000-4000-8000-000000000006" + with self.assertRaisesRegex(ValueError, "contradiz a origem conhecida"): + evaluate(self.dataset, results) + + def test_known_source_cannot_be_relabelled_as_belonging_to_the_requested_project(self): + results = self._runs() + run = next(r for r in results["runs"] if r["query_id"] == "Q002") + run["results"][0]["project_id"] = "60000000-0000-4000-8000-000000000006" + query = next(q for q in self.dataset["queries"] if q["id"] == "Q002") + query["project_id"] = "60000000-0000-4000-8000-000000000006" + query["expected_source_ids"] = [] + with self.assertRaisesRegex(ValueError, "contradiz a origem conhecida"): + evaluate(self.dataset, results) + + def test_rejects_non_finite_or_boolean_latency(self): + for latency in (float("nan"), float("inf"), True): + results = self._runs() + results["runs"][0]["latency_ms"] = latency + with self.subTest(latency=latency), self.assertRaisesRegex(ValueError, "latency_ms"): + evaluate(self.dataset, results) + + def test_rejects_malformed_run_and_result_entries(self): + results = self._runs() + results["runs"][0] = None + with self.assertRaisesRegex(ValueError, "cada execução"): + evaluate(self.dataset, results) + results = self._runs() + results["runs"][0]["results"][0] = None + with self.assertRaisesRegex(ValueError, "cada resultado"): + evaluate(self.dataset, results) + + def test_scoped_result_without_project_id_is_rejected(self): + results = self._runs() + run = next(r for r in results["runs"] if r["query_id"] == "Q002") + run["results"][0].pop("project_id") + with self.assertRaisesRegex(ValueError, "precisa de project_id"): + evaluate(self.dataset, results) + + def test_latency_budget_is_reported_per_query_and_aggregated(self): + results = self._runs() + results["runs"][0]["latency_ms"] = 2100 + report = evaluate(self.dataset, results, latency_budget_ms=2000) + self.assertEqual(report["latency"]["within_budget_count"], 23) + self.assertEqual(report["latency"]["p95_ms"], 100) + self.assertEqual(report["per_query"][0]["returned_results_at_k"][0]["rank"], 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/ai-service/tests/test_search_suite_runner.py b/ai-service/tests/test_search_suite_runner.py new file mode 100644 index 0000000..cb17cde --- /dev/null +++ b/ai-service/tests/test_search_suite_runner.py @@ -0,0 +1,102 @@ +import io +import json +import sys +import unittest +from pathlib import Path +from unittest.mock import patch +from urllib.error import HTTPError + +EVALUATION_DIR = Path(__file__).resolve().parents[1] / "evaluation" +sys.path.insert(0, str(EVALUATION_DIR)) + +from run_search_suite import run_query, run_suite, validate_api_base_url # noqa: E402 +from evaluate_search import load_json, DEFAULT_DATASET # noqa: E402 + + +class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *_args): + self.close() + + +class SearchSuiteRunnerTests(unittest.TestCase): + def test_query_sends_scope_and_secret_cookie_and_normalizes_ranked_sources(self): + response = FakeResponse(json.dumps({"items": [ + {"id": "chunk-1", "project_id": "project-a"}, + {"id": "chunk-2", "project_id": "project-a"}, + ]}).encode()) + with patch("run_search_suite.urlopen", return_value=response) as urlopen: + result = run_query( + "http://localhost:3001", "session-secret", + {"id": "Q001", "query": "login de cliente", "project_id": "project-a"}, 5, 3, + ) + request = urlopen.call_args.args[0] + self.assertIn("projeto_id=project-a", request.full_url) + self.assertIn("q=login+de+cliente", request.full_url) + self.assertEqual(request.get_header("Cookie"), "sinapse_session=session-secret") + self.assertEqual([item["source_id"] for item in result["results"]], ["chunk-1", "chunk-2"]) + self.assertTrue(all(item["project_id"] == "project-a" for item in result["results"])) + + def test_query_passes_technology_and_level_filters(self): + response = FakeResponse(b'{"items": []}') + with patch("run_search_suite.urlopen", return_value=response) as urlopen: + run_query( + "http://localhost:3001", "session-secret", + {"id": "Q002", "query": "login", "project_id": "project-a", "filters": { + "technology_id": "technology-a", "level": "pbi", + }}, 5, 3, + ) + url = urlopen.call_args.args[0].full_url + self.assertIn("tecnologia_id=technology-a", url) + self.assertIn("nivel=pbi", url) + + def test_http_failure_does_not_echo_response_body_or_credentials(self): + error = HTTPError("http://localhost", 503, "unavailable", {}, io.BytesIO(b"private detail")) + with patch("run_search_suite.urlopen", side_effect=error): + with self.assertRaisesRegex(RuntimeError, "HTTP 503") as raised: + run_query("http://localhost", "session-secret", { + "id": "Q001", "query": "login", "project_id": "project-a", + }, 5, 3) + self.assertNotIn("private detail", str(raised.exception)) + self.assertNotIn("session-secret", str(raised.exception)) + + def test_suite_requires_session_cookie_before_sending_requests(self): + dataset = load_json(DEFAULT_DATASET) + with self.assertRaisesRegex(ValueError, "SINAPSE_SESSION_COOKIE"): + run_suite(dataset, "http://localhost:3001", " ") + + def test_suite_refuses_to_send_session_cookie_to_remote_or_malformed_api_urls(self): + dataset = load_json(DEFAULT_DATASET) + for url in ("https://example.com", "http://example.com", "http://user:pass@localhost:3001", + "http://localhost:3001/api", "http://localhost:3001?next=remote"): + with self.subTest(url=url), self.assertRaisesRegex(ValueError, "HTTP local"): + run_suite(dataset, url, "session-secret") + + def test_api_base_accepts_loopback_http(self): + self.assertEqual(validate_api_base_url("http://localhost:3001/"), "http://localhost:3001") + self.assertEqual(validate_api_base_url("http://127.0.0.1:3001"), "http://127.0.0.1:3001") + self.assertEqual(validate_api_base_url("http://[::1]:3001"), "http://[::1]:3001") + + def test_suite_metadata_is_aggregated_and_does_not_record_api_url_or_cookie(self): + dataset = load_json(DEFAULT_DATASET) + with patch("run_search_suite.run_query", side_effect=lambda _url, _cookie, query, _limit, _timeout: { + "query_id": query["id"], "latency_ms": 50, "results": [], + }): + result = run_suite(dataset, "http://localhost:3001", "session-secret") + self.assertEqual(result["execution_metadata"]["query_count"], 24) + self.assertEqual(result["execution_metadata"]["latency_p95_ms"], 50) + self.assertEqual(result["execution_metadata"]["minimum_vector_similarity"], 0.55) + self.assertRegex(result["execution_metadata"]["code_revision"], r"^[0-9a-f]{40}$") + self.assertRegex(result["execution_metadata"]["dataset_sha256"], r"^[0-9a-f]{64}$") + self.assertRegex(result["execution_metadata"]["corpus_sha256"], r"^[0-9a-f]{64}$") + self.assertRegex(result["execution_metadata"]["source_snapshot_sha256"], r"^[0-9a-f]{64}$") + self.assertIsInstance(result["execution_metadata"]["working_tree_dirty"], bool) + serialized = json.dumps(result) + self.assertNotIn("localhost", serialized) + self.assertNotIn("session-secret", serialized) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/package.json b/backend/package.json index 6a9948c..b6e3bf1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,7 +16,8 @@ "audit:security": "npm audit --omit=dev", "test": "node scripts/test.mjs", "test:integration:s1": "tsx scripts/validate-s1.mts", - "test:integration:s105": "tsx scripts/validate-s105.mts" + "test:integration:s105": "tsx scripts/validate-s105.mts", + "test:integration:search": "tsx --test src/modules/search/search.repository.db.test.ts" }, "dependencies": { "axios": "^1.20.0", diff --git a/backend/src/config/env.test.ts b/backend/src/config/env.test.ts index a71a1e7..ee4d079 100644 --- a/backend/src/config/env.test.ts +++ b/backend/src/config/env.test.ts @@ -1,5 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { env } from "./env.js"; @@ -8,4 +9,28 @@ test("configuração de ambiente aplica valores padrão seguros", () => { assert.equal(typeof env.PORT, "number"); assert.equal(typeof env.POSTGRES_PORT, "number"); assert.match(env.AI_SERVICE_URL, /^https?:\/\//); + assert.equal(env.SEARCH_MIN_VECTOR_SIMILARITY, 0.55); +}); + +test("produção exige um segredo privado para a ingestão", () => { + const result = spawnSync(process.execPath, ["--import", "tsx", "-e", "import('./src/config/env.ts')"], { + cwd: process.cwd(), + env: { + ...process.env, + NODE_ENV: "production", + DOCUMENT_INGESTION_TOKEN: "", + }, + encoding: "utf8", + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Configure um segredo aleatório privado/); +}); + +test("limite configurável de documentos não pode exceder a capacidade da ingestão", () => { + const result = spawnSync(process.execPath, ["--import", "tsx", "-e", "import('./src/config/env.ts')"], { + cwd: process.cwd(), + env: { ...process.env, DOCUMENT_MAX_SIZE_MB: "21" }, + encoding: "utf8", + }); + assert.notEqual(result.status, 0); }); diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index d2c7adb..575a4df 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -18,7 +18,10 @@ const envSchema = z.object({ POSTGRES_HOST: z.string().default("localhost"), POSTGRES_PORT: z.coerce.number().default(5432), AI_SERVICE_URL: z.string().default("http://localhost:8000"), + DOCUMENT_INGESTION_TOKEN: z.string().default(""), 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), // S1-01 — Autenticação e segurança AUTH_MAX_LOGIN_ATTEMPTS: z.coerce.number().int().min(1).default(5), @@ -27,9 +30,24 @@ const envSchema = z.object({ AUTH_SESSION_MAX_HOURS: z.coerce.number().int().min(1).default(12), // S1-19/S1-22 — Documentos - DOCUMENT_MAX_SIZE_MB: z.coerce.number().positive().max(100).default(20), + DOCUMENT_MAX_SIZE_MB: z.coerce.number().positive().max(20).default(20), DOCUMENT_STORAGE_DIR: z.string().min(1).default("storage/documents"), DOCUMENT_EVENTS_WEBHOOK_URL: z.string().optional(), +}).superRefine((value, context) => { + if (value.DOCUMENT_INGESTION_TOKEN.length > 0 && value.DOCUMENT_INGESTION_TOKEN.length < 32) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["DOCUMENT_INGESTION_TOKEN"], + message: "DOCUMENT_INGESTION_TOKEN deve ter ao menos 32 caracteres.", + }); + } + if (value.NODE_ENV === "production" && value.DOCUMENT_INGESTION_TOKEN.length < 32) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["DOCUMENT_INGESTION_TOKEN"], + message: "Configure um segredo aleatório privado para a ingestão de documentos em produção.", + }); + } }); -export const env = envSchema.parse(process.env); \ No newline at end of file +export const env = envSchema.parse(process.env); diff --git a/backend/src/database/seed-lib.ts b/backend/src/database/seed-lib.ts index 3425832..de9a2e4 100644 --- a/backend/src/database/seed-lib.ts +++ b/backend/src/database/seed-lib.ts @@ -12,16 +12,20 @@ const fields: Record = { export interface SeedRecord { table: string; values: Record; source: Record } export interface Dataset { dataset: string; version: number; source_decision: Record; records: SeedRecord[] } export const fixturePath = resolve(process.cwd(), "../database/seed/fixtures/historical-v1.json"); +const fixtureV2Path = resolve(process.cwd(), "../database/seed/fixtures/historical-v2.json"); export function validateDataset(input: unknown): asserts input is Dataset { const data = input as Dataset; - if (data?.dataset !== "pre06-historical-v1" || data.version !== 1 || !data.source_decision?.decision || !Array.isArray(data.records) || !data.records.length) throw new Error("Manifesto inválido"); + const version = data?.dataset === "pre06-historical-v1" && data.version === 1 ? 1 + : data?.dataset === "pre06-historical-v2" && data.version === 2 ? 2 : null; + if (!version || !data.source_decision?.decision || !Array.isArray(data.records) || !data.records.length) throw new Error("Manifesto inválido"); const seen = new Map(); for (const row of data.records) { const columns = fields[row.table]; if (!columns || !row.values || !isDeepStrictEqual(Object.keys(row.values).sort(), [...columns].sort())) throw new Error("Tabela ou campos não permitidos"); const id = row.values.id; - if (typeof id !== "string" || !/^60000000-0000-4000-8000-\d{12}$/.test(id) || seen.has(id)) throw new Error("ID inválido ou duplicado"); + const idPrefix = version === 1 ? "60000000" : "62000000"; + if (typeof id !== "string" || !new RegExp(`^${idPrefix}-0000-4000-8000-\\d{12}$`).test(id) || seen.has(id)) throw new Error("ID inválido ou duplicado"); const source = row.source; if (!source || !/^https:\/\/github.com\/Galaticos-API\/API-[123]$/.test(source.repository) || !/^[a-f0-9]{40}$/.test(source.revision) || !/^[a-f0-9]{64}$/.test(source.source_sha256) @@ -30,20 +34,23 @@ export function validateDataset(input: unknown): asserts input is Dataset { || !source.transformation || !source.url?.startsWith(`${source.repository}/blob/${source.revision}/`)) throw new Error("Origem ausente ou não permitida"); if (row.table === "documento") { if (seen.get(String(row.values.projeto_id))?.table !== "projeto") throw new Error("Projeto de documento inválido"); - if (!/^database\/seed\/curated\/[a-z0-9-]+\.md$/.test(String(row.values.caminho))) throw new Error("Caminho não permitido"); + const pathPattern = version === 1 ? /^database\/seed\/curated\/[a-z0-9-]+\.md$/ : /^database\/seed\/curated\/v2\/[a-z0-9-]+\.md$/; + if (!pathPattern.test(String(row.values.caminho))) throw new Error("Caminho não permitido"); } if (row.table === "chunk") { const parent = seen.get(String(row.values.entidade_id)); const metadata = row.values.metadados_json as Record; if (row.values.entidade_tipo !== "documento" || parent?.table !== "documento" || parent.values.projeto_id !== row.values.projeto_id - || metadata?.dataset !== data.dataset || metadata.source_url !== source.url || metadata.embedding_status !== "pending") throw new Error("Chunk sem isolamento ou origem"); + || metadata?.dataset !== data.dataset || metadata.source_url !== source.url || metadata.embedding_status !== "pending" + || (version === 2 && source.locator.startsWith("GRF-") && metadata.source_locator !== source.locator)) throw new Error("Chunk sem isolamento ou origem"); } seen.set(id, row); } } export async function loadDataset(): Promise { - const data: unknown = JSON.parse(await readFile(fixturePath, "utf8")); + const selectedFixture = process.env.SEED_DATASET_VERSION === "2" ? fixtureV2Path : fixturePath; + const data: unknown = JSON.parse(await readFile(selectedFixture, "utf8")); validateDataset(data); for (const row of data.records.filter(row => row.table === "documento")) { const text = await readFile(resolve(process.cwd(), "..", String(row.values.caminho)), "utf8"); @@ -52,6 +59,9 @@ export async function loadDataset(): Promise { return data; } +function versionPrefix(data: Dataset): string { return data.version === 1 ? "60000000" : "62000000"; } +function versionAuditPrefix(data: Dataset): string { return data.version === 1 ? "61000000" : "63000000"; } + export function validateTarget(url: string | undefined, environment: string | undefined): string { if (!url || (environment === "production" && process.env.ALLOW_SEED !== "true")) { throw new Error("Seed restrito a banco de desenvolvimento/teste explícito"); @@ -71,7 +81,7 @@ export async function applyDataset(client: PoolClient, data: Dataset): Promise { + delete process.env.SEED_DATASET_VERSION; const data = await loadDataset(); assert.equal(data.records.filter(row => row.table === "projeto").length, 3); assert.equal(data.records.filter(row => row.table === "documento").length, 6); assert.equal(data.records.filter(row => row.table === "chunk").length, 6); assert.equal(data.records.some(row => ["usuario", "competencia", "alocacao"].includes(row.table)), false); }); +test("PRE-06 v2 preserva o acervo e publica localizadores GRF como metadados pesquisáveis", async () => { + process.env.SEED_DATASET_VERSION = "2"; + try { + const data = await loadDataset(); + assert.equal(data.dataset, "pre06-historical-v2"); + assert.equal(data.version, 2); + const locators = data.records.filter(row => row.table === "chunk").map(row => (row.values.metadados_json as Record).source_locator).filter(Boolean); + assert.deepEqual(locators.sort(), ["GRF-01", "GRF-08"]); + assert.equal(data.records.filter(row => row.table === "chunk").length, 6); + } finally { delete process.env.SEED_DATASET_VERSION; } +}); test("recusa registro sem origem", async () => { const data = await loadDataset(); data.records[0].source.revision = ""; assert.throws(() => validateDataset(data)); diff --git a/backend/src/modules/documents/document-ingestion.test.ts b/backend/src/modules/documents/document-ingestion.test.ts new file mode 100644 index 0000000..82bc0d8 --- /dev/null +++ b/backend/src/modules/documents/document-ingestion.test.ts @@ -0,0 +1,83 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import axios from "axios"; +import { AppError } from "../../shared/errors.js"; +import { HttpDocumentIngestionClient } from "./document-ingestion.js"; +import type { PendingIngestionDocument } from "./documents.types.js"; + +const document: PendingIngestionDocument = { + id: "60000000-0000-4000-8000-000000000001", + projeto_id: "60000000-0000-4000-8000-000000000002", + nome: "manual.txt", caminho: "60000000-0000-4000-8000-000000000002/60000000-0000-4000-8000-000000000001", + status_processamento: "processando", mime: "text/plain", extensao: ".txt", + processamento_lease_id: "60000000-0000-4000-8000-000000000003", +}; +const TEST_INGESTION_TOKEN = "test-only-document-ingestion-token-0123456789abcdef"; + +test("cliente de ingestão transmite documento e escopo ao serviço local com autenticação interna", async () => { + const originalPost = axios.post; + let args: unknown[] = []; + axios.post = (async (...values: unknown[]) => { + args = values; + return { data: { document_id: document.id, project_id: document.projeto_id, chunks: [{ chunk_index: 0, text: "Conteúdo", embedding: Array(1024).fill(0.1), metadata: { document_id: document.id, project_id: document.projeto_id, source_name: document.nome } }] } }; + }) as typeof axios.post; + try { + const chunks = await new HttpDocumentIngestionClient("http://local-ai/", TEST_INGESTION_TOKEN).process(document, Buffer.from("conteúdo")); + assert.equal(args[0], "http://local-ai/documents/process"); + assert.deepEqual(args[1], { + document_id: document.id, project_id: document.projeto_id, filename: document.nome, + content_base64: Buffer.from("conteúdo").toString("base64"), + }); + assert.equal((args[2] as { headers: Record }).headers["X-Document-Ingestion-Token"], TEST_INGESTION_TOKEN); + assert.equal(chunks.length, 1); + } finally { + axios.post = originalPost; + } +}); + +test("cliente de ingestão rejeita resposta parcial ou embedding incompatível", async () => { + const originalPost = axios.post; + axios.post = (async () => ({ data: { chunks: [{ chunk_index: 0, text: "bad", embedding: [1], metadata: {} }] } })) as typeof axios.post; + try { + await assert.rejects(new HttpDocumentIngestionClient("http://local-ai/", TEST_INGESTION_TOKEN).process(document, Buffer.from("x")), /dados de processamento inválidos/); + } finally { + axios.post = originalPost; + } +}); + +test("cliente rejeita resposta de outro escopo e metadados inconsistentes", async () => { + const originalPost = axios.post; + axios.post = (async () => ({ data: { + document_id: document.id, project_id: "60000000-0000-4000-8000-000000000099", + chunks: [{ chunk_index: 0, text: "Conteúdo", embedding: Array(1024).fill(0.1), metadata: { document_id: document.id, project_id: document.projeto_id, source_name: document.nome } }], + } })) as typeof axios.post; + try { + await assert.rejects(new HttpDocumentIngestionClient("http://local-ai", TEST_INGESTION_TOKEN).process(document, Buffer.from("x")), /dados de processamento inválidos/); + } finally { + axios.post = originalPost; + } +}); + +test("cliente rejeita chunk acima do limite contratual", async () => { + const originalPost = axios.post; + axios.post = (async () => ({ data: { + document_id: document.id, project_id: document.projeto_id, + chunks: [{ chunk_index: 0, text: "x".repeat(1001), embedding: Array(1024).fill(0.1), metadata: { document_id: document.id, project_id: document.projeto_id, source_name: document.nome } }], + } })) as typeof axios.post; + try { + await assert.rejects(new HttpDocumentIngestionClient("http://local-ai", TEST_INGESTION_TOKEN).process(document, Buffer.from("x")), /dados de processamento inválidos/); + } finally { + axios.post = originalPost; + } +}); + +test("cliente não chama a IA sem segredo interno configurado", async () => { + const originalPost = axios.post; + axios.post = (async () => { throw new Error("não deve chamar o serviço remoto"); }) as typeof axios.post; + try { + await assert.rejects(new HttpDocumentIngestionClient("http://local-ai/", "").process(document, Buffer.from("x")), + (error: unknown) => error instanceof AppError && error.code === "DOCUMENT_INGESTION_UNCONFIGURED"); + } finally { + axios.post = originalPost; + } +}); diff --git a/backend/src/modules/documents/document-ingestion.ts b/backend/src/modules/documents/document-ingestion.ts new file mode 100644 index 0000000..2fdaa88 --- /dev/null +++ b/backend/src/modules/documents/document-ingestion.ts @@ -0,0 +1,62 @@ +import axios from "axios"; +import { env } from "../../config/env.js"; +import { AppError } from "../../shared/errors.js"; +import type { ExtractedDocumentChunk, PendingIngestionDocument } from "./documents.types.js"; + +export interface DocumentIngestionClient { + process(document: PendingIngestionDocument, content: Buffer): Promise; +} + +export class HttpDocumentIngestionClient implements DocumentIngestionClient { + constructor( + private readonly baseUrl = env.AI_SERVICE_URL, + private readonly token = env.DOCUMENT_INGESTION_TOKEN, + ) {} + + async process(document: PendingIngestionDocument, content: Buffer): Promise { + if (this.token.length < 32) { + throw new AppError("A autenticação interna da ingestão não está configurada no backend.", 503, "DOCUMENT_INGESTION_UNCONFIGURED"); + } + try { + const response = await axios.post(`${this.baseUrl.replace(/\/$/, "")}/documents/process`, { + document_id: document.id, + project_id: document.projeto_id, + filename: document.nome, + content_base64: content.toString("base64"), + }, { + timeout: 600_000, + maxContentLength: 15 * 1024 * 1024, + maxBodyLength: 30 * 1024 * 1024, + headers: { "X-Document-Ingestion-Token": this.token }, + }); + if (response.data?.document_id !== document.id || response.data?.project_id !== document.projeto_id) { + throw new Error("response scope mismatch"); + } + const chunks = response.data?.chunks; + if (!Array.isArray(chunks) || chunks.length === 0 || chunks.length > 500) throw new Error("empty or malformed extraction"); + return chunks.map((chunk: unknown, index: number) => { + if (typeof chunk !== "object" || chunk === null) throw new Error("malformed chunk"); + const candidate = chunk as Record; + const metadata = candidate.metadata as Record | null; + if (candidate.chunk_index !== index || typeof candidate.text !== "string" || !candidate.text.trim() || candidate.text.length > 1000 + || !Array.isArray(candidate.embedding) || candidate.embedding.length !== 1024 + || candidate.embedding.some((value) => typeof value !== "number" || !Number.isFinite(value)) + || typeof metadata !== "object" || metadata === null + || metadata.document_id !== document.id || metadata.project_id !== document.projeto_id + || metadata.source_name !== document.nome) throw new Error("invalid chunk data"); + return candidate as unknown as ExtractedDocumentChunk; + }); + } catch (error) { + if (error instanceof Error && error.message === "empty or malformed extraction") { + throw new AppError("O serviço local não conseguiu extrair conteúdo indexável do documento.", 422, "DOCUMENT_EXTRACTION_EMPTY"); + } + if (axios.isAxiosError(error) && error.response) { + throw new AppError("O serviço local recusou o processamento do documento.", 422, "DOCUMENT_PROCESSING_REJECTED"); + } + if (axios.isAxiosError(error)) { + throw new AppError("O serviço local de processamento está indisponível ou excedeu o tempo limite.", 503, "DOCUMENT_PROCESSING_UNAVAILABLE"); + } + throw new AppError("O serviço local retornou dados de processamento inválidos.", 502, "DOCUMENT_PROCESSING_INVALID"); + } + } +} diff --git a/backend/src/modules/documents/documents.controller.ts b/backend/src/modules/documents/documents.controller.ts index 5a60830..27d7a11 100644 --- a/backend/src/modules/documents/documents.controller.ts +++ b/backend/src/modules/documents/documents.controller.ts @@ -57,6 +57,15 @@ export class DocumentsController { next(error); } }; + + retry = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + await this.service.retryProcessing(paramOf(req.params.projectId), paramOf(req.params.documentId)); + res.status(202).json({ status_processamento: "pendente" }); + } catch (error) { + next(error); + } + }; } export const documentsController = new DocumentsController(); diff --git a/backend/src/modules/documents/documents.fakes.ts b/backend/src/modules/documents/documents.fakes.ts index 340dfe1..bb54749 100644 --- a/backend/src/modules/documents/documents.fakes.ts +++ b/backend/src/modules/documents/documents.fakes.ts @@ -42,6 +42,7 @@ export class FakeDocumentsRepository extends DocumentsRepository { public events = new Map(); public failCreate = false; public failRemove = false; + public failCompleteIngestion = false; public chunks = new Map(); public storageOperations = new Map(); @@ -187,6 +188,38 @@ export class FakeDocumentsRepository extends DocumentsRepository { return this.rows.some((row) => row.caminho === caminho); } + async claimPendingIngestion() { + const row = this.rows.find((item) => item.status_processamento === "pendente"); + if (!row) return []; + row.status_processamento = "processando"; + return [{ id: row.id, projeto_id: row.projeto_id, nome: row.nome, caminho: row.caminho, + status_processamento: row.status_processamento, mime: row.mime, extensao: row.extensao, + processamento_lease_id: `lease-${row.id}` }]; + } + + async completeIngestion(document: { id: string }, chunks: Array<{ text: string }>) { + if (this.failCompleteIngestion) throw new Error("falha simulada no commit"); + const row = this.rows.find((item) => item.id === document.id); + if (row) row.status_processamento = "processado"; + this.chunks.set(document.id, chunks.length); + } + + async failIngestion(documentId: string, _projectId: string, _leaseId: string, reason: string) { + const row = this.rows.find((item) => item.id === documentId); + if (row) { + row.status_processamento = "falha"; + row.processamento_erro = reason; + } + } + + async retryIngestion(projectId: string, documentId: string) { + const row = this.rows.find((item) => item.id === documentId && item.projeto_id === projectId && item.status_processamento === "falha"); + if (!row) return false; + row.status_processamento = "pendente"; + row.processamento_erro = null; + return true; + } + async listPendingEvents(limit: number): Promise { return [...this.events.entries()] .filter(([, value]) => value.status !== "publicado") @@ -218,6 +251,7 @@ export class FakeStorage implements DocumentStorage { public failSave = false; public failStage = false; public failFinalize = false; + public failRead = false; async save(key: string, content: Buffer): Promise { if (this.failSave) throw new Error("disco cheio"); @@ -233,6 +267,13 @@ export class FakeStorage implements DocumentStorage { } } + async read(key: string): Promise { + if (this.failRead) throw new Error("falha simulada"); + const content = this.files.get(key); + if (!content) throw new Error("arquivo ausente"); + return content; + } + async remove(key: string): Promise { this.files.delete(key); this.uploads.delete(key); diff --git a/backend/src/modules/documents/documents.repository.db.test.ts b/backend/src/modules/documents/documents.repository.db.test.ts index f77b660..5bbf625 100644 --- a/backend/src/modules/documents/documents.repository.db.test.ts +++ b/backend/src/modules/documents/documents.repository.db.test.ts @@ -49,6 +49,40 @@ test("S1-19/S1-22: documentos, auditoria e outbox no PostgreSQL", { skip: !proce assert.equal((await pool.query("SELECT count(*)::int n FROM documento WHERE id=$1", [orphan])).rows[0].n, 0); }); + await t.test("lease expirado impede worker antigo de concluir ou sobrescrever a nova tentativa", async () => { + const fenced = randomUUID(); + await pool.query("UPDATE documento SET status_processamento='processado' WHERE id = ANY($1::uuid[])", [[plain, foreign]]); + await pool.query( + `INSERT INTO documento(id,projeto_id,nome,extensao,mime,tamanho_bytes,caminho,usuario_id,status_processamento) + VALUES ($1,$2,'lease.txt','.txt','text/plain',8,$3,$4,'pendente')`, + [fenced, project, `${project}/${fenced}`, user], + ); + + const first = (await repo.claimPendingIngestion(1))[0]; + assert.equal(first?.id, fenced); + await pool.query("UPDATE documento SET processamento_bloqueado_ate=CURRENT_TIMESTAMP - INTERVAL '1 second' WHERE id=$1", [fenced]); + const second = (await repo.claimPendingIngestion(1))[0]; + assert.equal(second?.id, fenced); + assert.notEqual(second?.processamento_lease_id, first?.processamento_lease_id); + + await repo.failIngestion(fenced, project, first!.processamento_lease_id, "falha antiga"); + const stillOwned = (await pool.query( + "SELECT status_processamento, processamento_lease_id FROM documento WHERE id=$1", [fenced], + )).rows[0]; + assert.equal(stillOwned.status_processamento, "processando"); + assert.equal(stillOwned.processamento_lease_id, second!.processamento_lease_id); + const chunks = [{ chunk_index: 0, text: "conteúdo válido", embedding: Array(1024).fill(0.1), metadata: {} }]; + await assert.rejects(repo.completeIngestion(first!, chunks), /reserva de ingestão/); + await repo.completeIngestion(second!, chunks); + const completed = (await pool.query( + "SELECT status_processamento, processamento_lease_id FROM documento WHERE id=$1", [fenced], + )).rows[0]; + assert.equal(completed.status_processamento, "processado"); + assert.equal(completed.processamento_lease_id, null); + assert.equal((await pool.query("SELECT count(*)::int n FROM chunk WHERE entidade_id=$1", [fenced])).rows[0].n, 1); + await pool.query("UPDATE documento SET status_processamento='pendente' WHERE id = ANY($1::uuid[])", [[plain, foreign]]); + }); + await t.test("remove documento não indexado sem gerar evento e é idempotente", async () => { const result = await repo.remove({ id: plain, projetoId: project, usuarioId: user }); assert.equal(result?.indexado, false); @@ -276,7 +310,7 @@ test("S1-19/S1-22: documentos, auditoria e outbox no PostgreSQL", { skip: !proce await archiver.query("COMMIT"); await assert.rejects(removal, ArchiveConflict); assert.equal((await repo.findById(archivedProject, archivedDocument))?.id, archivedDocument); - assert.equal((await pool.query("SELECT count(*)::int AS total FROM chunk WHERE id=$1", [archivedChunk])).rows[0].total, 1); + assert.equal((await pool.query("SELECT count(*)::int AS total FROM chunk WHERE id=$1", [archivedChunk])).rows[0].total, 0, "arquivar o projeto expurga seus trechos do acervo"); assert.equal((await pool.query("SELECT count(*)::int AS total FROM auditoria WHERE entidade_id=$1 AND acao='REMOVER_DOCUMENTO'", [archivedDocument])).rows[0].total, 0); } catch (error) { await archiver.query("ROLLBACK").catch(() => undefined); @@ -285,10 +319,75 @@ test("S1-19/S1-22: documentos, auditoria e outbox no PostgreSQL", { skip: !proce archiver.release(); } }); + + await t.test("falha de IA seguida de retry manual conclui sem chunks parciais ou duplicados", async () => { + const retriable = randomUUID(); + try { + await repo.create({ + id: retriable, projetoId: project, nome: "retry-controlado.txt", extensao: ".txt", mime: "text/plain", + tamanhoBytes: 32, caminho: `${project}/${retriable}`, usuarioId: user, + }); + await repo.completeUploadOperation(retriable); + + const firstAttempt = (await repo.claimPendingIngestion(50)).find((item) => item.id === retriable); + assert.ok(firstAttempt, "o worker deve reivindicar o upload pronto"); + await repo.failIngestion(retriable, project, firstAttempt.processamento_lease_id, "falha simulada do serviço IA"); + const failed = (await pool.query( + "SELECT status_processamento, processamento_tentativas FROM documento WHERE id=$1", [retriable], + )).rows[0]; + assert.equal(failed.status_processamento, "falha"); + assert.equal(failed.processamento_tentativas, 1); + assert.equal((await pool.query("SELECT count(*)::int n FROM chunk WHERE entidade_id=$1", [retriable])).rows[0].n, 0); + + assert.equal(await repo.retryIngestion(project, retriable), true); + const secondAttempt = (await repo.claimPendingIngestion(50)).find((item) => item.id === retriable); + assert.ok(secondAttempt, "retry manual deve deixar o documento novamente disponível"); + assert.notEqual(secondAttempt.processamento_lease_id, firstAttempt.processamento_lease_id); + const extracted = [{ chunk_index: 0, text: "conteúdo recuperado pelo retry", embedding: Array(1024).fill(0.1), metadata: {} }]; + await repo.completeIngestion(secondAttempt, extracted); + await assert.rejects(repo.completeIngestion(secondAttempt, extracted), /reserva de ingestão/); + + const completed = (await pool.query( + "SELECT status_processamento, processamento_tentativas FROM documento WHERE id=$1", [retriable], + )).rows[0]; + assert.equal(completed.status_processamento, "processado"); + assert.equal(completed.processamento_tentativas, 2); + assert.equal((await pool.query("SELECT count(*)::int n FROM chunk WHERE entidade_id=$1", [retriable])).rows[0].n, 1); + } finally { + await pool.query("DELETE FROM chunk WHERE entidade_id=$1", [retriable]); + await pool.query("DELETE FROM documento_operacao_armazenamento WHERE documento_id=$1", [retriable]); + await pool.query("DELETE FROM auditoria WHERE entidade_id=$1", [retriable]); + await pool.query("DELETE FROM documento WHERE id=$1", [retriable]); + } + }); + + await t.test("ingestão que termina após arquivamento do projeto não pode repovoar o acervo", async () => { + const pending = randomUUID(); + const failed = randomUUID(); + await pool.query( + `INSERT INTO documento(id,projeto_id,nome,extensao,mime,tamanho_bytes,caminho,usuario_id,status_processamento) + VALUES ($1,$2,'pendente.txt','.txt','text/plain',8,$3,$4,'processando')`, + [pending, project, `${project}/${pending}`, user], + ); + await pool.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [project]); + await pool.query( + `INSERT INTO documento(id,projeto_id,nome,extensao,mime,tamanho_bytes,caminho,usuario_id,status_processamento) + VALUES ($1,$2,'falha.txt','.txt','text/plain',8,$3,$4,'falha')`, + [failed, project, `${project}/${failed}`, user], + ); + await assert.rejects(repo.retryIngestion(project, failed), ArchiveConflict); + assert.equal((await pool.query("SELECT status_processamento FROM documento WHERE id=$1", [failed])).rows[0].status_processamento, "falha"); + await assert.rejects(repo.completeIngestion({ + id: pending, projeto_id: project, nome: "pendente.txt", caminho: `${project}/${pending}`, + status_processamento: "processando", mime: "text/plain", extensao: ".txt", processamento_lease_id: randomUUID(), + }, [{ chunk_index: 0, text: "conteúdo que não pode ser republicado", embedding: Array(1024).fill(0.1), metadata: {} }]), /Projeto arquivado/); + assert.equal((await pool.query("SELECT count(*)::int AS total FROM chunk WHERE projeto_id=$1", [project])).rows[0].total, 0); + }); } finally { await pool.query("DELETE FROM evento_integracao WHERE chave_idempotencia = ANY($1::text[])", [[removalEventKey(indexed), removalEventKey(plain)]]); await pool.query("DELETE FROM auditoria WHERE entidade_id = ANY($1::uuid[])", [[plain, indexed, foreign]]); await pool.query("DELETE FROM chunk WHERE id=$1", [chunk]); + await pool.query("DELETE FROM knowledge_index_queue WHERE project_id = ANY($1::uuid[])", [[project, other]]).catch(() => undefined); await pool.query("DELETE FROM projeto WHERE id = ANY($1::uuid[])", [[project, other]]); await pool.query("DELETE FROM usuario WHERE id=$1", [user]); await pool.end(); diff --git a/backend/src/modules/documents/documents.repository.ts b/backend/src/modules/documents/documents.repository.ts index d13cafe..fc78144 100644 --- a/backend/src/modules/documents/documents.repository.ts +++ b/backend/src/modules/documents/documents.repository.ts @@ -8,6 +8,8 @@ import { type DocumentMaintenanceStats, type DocumentRecord, type DocumentRemovedEvent, + type ExtractedDocumentChunk, + type PendingIngestionDocument, type RemovalResult, type RemoveDocumentInput, type StoredDocument, @@ -37,6 +39,7 @@ export interface DocumentPage { const SELECT_COLUMNS = ` d.id, d.projeto_id, d.nome, d.extensao, d.mime, d.tamanho_bytes, d.status_processamento, + d.processamento_erro, d.processamento_tentativas, d.usuario_id AS autor_id, u.nome AS autor_nome, EXISTS ( SELECT 1 FROM documento_operacao_armazenamento op @@ -85,6 +88,120 @@ export class DocumentsRepository { return result.rows[0] ?? null; } + async claimPendingIngestion(limit: number): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const claimed = await client.query( + `WITH picked AS ( + SELECT id FROM documento + WHERE status_processamento IN ('pendente', 'processando') + AND processamento_proxima_tentativa <= CURRENT_TIMESTAMP + AND (processamento_bloqueado_ate IS NULL OR processamento_bloqueado_ate <= CURRENT_TIMESTAMP) + AND NOT EXISTS ( + SELECT 1 FROM documento_operacao_armazenamento op + WHERE op.documento_id = documento.id AND op.acao='finalizar_upload' AND op.status='pendente' + ) + ORDER BY processamento_proxima_tentativa, created_at, id + LIMIT $1 FOR UPDATE SKIP LOCKED + ) + UPDATE documento d + SET status_processamento = 'processando', + processamento_bloqueado_ate = CURRENT_TIMESTAMP + INTERVAL '30 minutes', + processamento_lease_id = gen_random_uuid(), + processamento_erro = NULL, + updated_at = CURRENT_TIMESTAMP + FROM picked WHERE d.id = picked.id + RETURNING d.id, d.projeto_id, d.nome, d.caminho, d.status_processamento, d.mime, d.extensao, + d.processamento_lease_id`, + [limit], + ); + await client.query("COMMIT"); + return claimed.rows; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async completeIngestion(document: PendingIngestionDocument, chunks: ExtractedDocumentChunk[]): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const project = await client.query<{ status: string }>("SELECT status FROM projeto WHERE id=$1 FOR SHARE", [document.projeto_id]); + if (project.rows[0]?.status === "arquivado") throw new Error("Projeto arquivado não aceita indexação de documentos."); + await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1::text,604006))", [document.projeto_id]); + const locked = await client.query<{ status_processamento: string; processamento_lease_id: string | null }>( + "SELECT status_processamento, processamento_lease_id FROM documento WHERE id=$1 AND projeto_id=$2 FOR UPDATE", + [document.id, document.projeto_id], + ); + if (locked.rows[0]?.status_processamento !== "processando" + || locked.rows[0]?.processamento_lease_id !== document.processamento_lease_id) { + throw new Error("A reserva de ingestão do documento expirou ou pertence a outra execução."); + } + await client.query("DELETE FROM chunk WHERE entidade_tipo='documento' AND entidade_id=$1 AND projeto_id=$2", [document.id, document.projeto_id]); + for (const chunk of chunks) { + await client.query( + `INSERT INTO chunk (projeto_id, entidade_tipo, entidade_id, texto, metadados_json, embedding) + VALUES ($1, 'documento', $2, $3, $4::jsonb, $5::vector)`, + [document.projeto_id, document.id, chunk.text, JSON.stringify({ + ...chunk.metadata, document_id: document.id, project_id: document.projeto_id, + chunk_index: chunk.chunk_index, source_name: document.nome, + }), `[${chunk.embedding.join(",")}]`], + ); + } + await client.query( + `UPDATE documento SET status_processamento='processado', processamento_erro=NULL, + processamento_bloqueado_ate=NULL, processamento_lease_id=NULL, + processamento_tentativas=processamento_tentativas+1, + updated_at=CURRENT_TIMESTAMP WHERE id=$1 AND projeto_id=$2`, + [document.id, document.projeto_id], + ); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async failIngestion(documentId: string, projectId: string, leaseId: string, reason: string): Promise { + await this.pool.query( + `UPDATE documento SET status_processamento='falha', processamento_erro=$3, + processamento_bloqueado_ate=NULL, processamento_lease_id=NULL, + processamento_tentativas=processamento_tentativas+1, + processamento_proxima_tentativa=CURRENT_TIMESTAMP + INTERVAL '1 hour', updated_at=CURRENT_TIMESTAMP + WHERE id=$1 AND projeto_id=$2 AND status_processamento='processando' AND processamento_lease_id=$4`, + [documentId, projectId, reason.slice(0, 300), leaseId], + ); + } + + async retryIngestion(projectId: string, documentId: string): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + await lockHierarchy(client); + await assertWritable(client, "projeto", projectId); + const result = await client.query( + `UPDATE documento SET status_processamento='pendente', processamento_erro=NULL, + processamento_proxima_tentativa=CURRENT_TIMESTAMP, processamento_bloqueado_ate=NULL, + processamento_lease_id=NULL, + updated_at=CURRENT_TIMESTAMP WHERE id=$1 AND projeto_id=$2 AND status_processamento='falha'`, + [documentId, projectId], + ); + await client.query("COMMIT"); + return (result.rowCount ?? 0) > 0; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + async create(input: CreateDocumentInput): Promise { const client = await this.pool.connect(); try { diff --git a/backend/src/modules/documents/documents.routes.test.ts b/backend/src/modules/documents/documents.routes.test.ts index a650da4..8330a8b 100644 --- a/backend/src/modules/documents/documents.routes.test.ts +++ b/backend/src/modules/documents/documents.routes.test.ts @@ -127,3 +127,13 @@ test("DELETE é idempotente: 204 na primeira e na repetição", async () => { test("DELETE com id de documento malformado retorna 400", async () => { assert.equal((await fetch(`${baseUrl}/${PROJECT_ID}/documents/abc`, { method: "DELETE" })).status, 400); }); + +test("reprocessamento exige sessão com escrita e agenda novamente o documento falho", async () => { + const id = "c0000000-0000-4000-8000-0000000000ab"; + repository.seed({ id, projeto_id: PROJECT_ID, caminho: `${PROJECT_ID}/${id}`, status_processamento: "falha" }); + const response = await fetch(`${baseUrl}/${PROJECT_ID}/documents/${id}/retry`, { method: "POST" }); + assert.equal(response.status, 202); + assert.equal((await response.json() as { status_processamento: string }).status_processamento, "pendente"); + assert.equal(repository.rows.find((item) => item.id === id)?.status_processamento, "pendente"); + assert.equal((await fetch(`${baseUrl}/${PROJECT_ID}/documents/${id}/retry`, { method: "POST" })).status, 202); +}); diff --git a/backend/src/modules/documents/documents.routes.ts b/backend/src/modules/documents/documents.routes.ts index eb258ae..0e94bb4 100644 --- a/backend/src/modules/documents/documents.routes.ts +++ b/backend/src/modules/documents/documents.routes.ts @@ -52,6 +52,8 @@ export function createDocumentsRouter( */ router.post("/", canWrite, binaryBody, controller.upload); + router.post("/:documentId/retry", canWrite, controller.retry); + /** * @swagger * /api/v1/projects/{projectId}/documents/{documentId}: diff --git a/backend/src/modules/documents/documents.service.test.ts b/backend/src/modules/documents/documents.service.test.ts index e8c0412..e821ca8 100644 --- a/backend/src/modules/documents/documents.service.test.ts +++ b/backend/src/modules/documents/documents.service.test.ts @@ -257,6 +257,56 @@ test("falha ao finalizar o arquivo fica marcada e é recuperável sem repetir o assert.equal(storage.files.size, 1); }); +test("pipeline assíncrono persiste chunks via backend e conclui o documento", async () => { + const repository = new FakeDocumentsRepository(); + const storage = new FakeStorage(); + const extracted = [{ chunk_index: 0, text: "Conteúdo extraído", embedding: Array(1024).fill(0.1), metadata: { project_id: PROJECT_ID } }]; + const service = new DocumentsService(repository, storage, new FakePublisher(), projectLookup, LIMIT, { + async process(document, content) { + assert.equal(document.projeto_id, PROJECT_ID); + assert.equal(content.toString(), "documento de texto longo o suficiente"); + return extracted; + }, + }); + const created = await service.upload({ projetoId: PROJECT_ID, usuarioId: USER_ID, fileName: "manual.txt", content: Buffer.from("documento de texto longo o suficiente") }); + assert.equal(created.status_processamento, "pendente"); + await service.processPendingDocuments(); + assert.equal(repository.rows[0].status_processamento, "processado"); + assert.equal(repository.chunks.get(created.id), 1); +}); + +test("falha de ingestão fica visível e pode ser reprocessada sem duplicar chunks", async () => { + const repository = new FakeDocumentsRepository(); + const storage = new FakeStorage(); + let unavailable = true; + const service = new DocumentsService(repository, storage, new FakePublisher(), projectLookup, LIMIT, { + async process() { + if (unavailable) throw new AppError("O serviço local de processamento está indisponível.", 503, "DOCUMENT_PROCESSING_UNAVAILABLE"); + return [{ chunk_index: 0, text: "Conteúdo válido", embedding: Array(1024).fill(0.2), metadata: {} }]; + }, + }); + const created = await service.upload({ projetoId: PROJECT_ID, usuarioId: USER_ID, fileName: "manual.txt", content: Buffer.from("conteúdo para ingestão") }); + await service.processPendingDocuments(); + assert.equal(repository.rows[0].status_processamento, "falha"); + assert.match(repository.rows[0].processamento_erro ?? "", /indisponível/); + await service.retryProcessing(PROJECT_ID, created.id); + await service.retryProcessing(PROJECT_ID, created.id); + assert.equal(repository.rows[0].status_processamento, "pendente"); + unavailable = false; + await service.processPendingDocuments(); + await service.processPendingDocuments(); + assert.equal(repository.rows[0].status_processamento, "processado"); + assert.equal(repository.chunks.get(created.id), 1); +}); + +test("retry preserva escopo e não reinicia documento já processado", async () => { + const { service, repository } = setup(); + repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho: `${PROJECT_ID}/${DOCUMENT_ID}` }); + await assert.rejects(service.retryProcessing(OTHER_PROJECT_ID, DOCUMENT_ID), NotFoundError); + repository.rows[0].status_processamento = "processado"; + await assert.rejects(service.retryProcessing(PROJECT_ID, DOCUMENT_ID), ValidationError); +}); + test("listagem usa cursor estável sem duplicar documentos entre páginas", async () => { const { service, repository } = setup(); for (let index = 0; index < 23; index += 1) { diff --git a/backend/src/modules/documents/documents.service.ts b/backend/src/modules/documents/documents.service.ts index 05bbe64..bff2b40 100644 --- a/backend/src/modules/documents/documents.service.ts +++ b/backend/src/modules/documents/documents.service.ts @@ -6,6 +6,7 @@ import { ProjectsRepository } from "../projects/projects.repository.js"; import { DocumentsRepository } from "./documents.repository.js"; import { HttpDocumentEventPublisher, type DocumentEventPublisher } from "./documents.events.js"; import { LocalDocumentStorage, type DocumentStorage } from "./documents.storage.js"; +import { HttpDocumentIngestionClient, type DocumentIngestionClient } from "./document-ingestion.js"; import { ALLOWED_EXTENSIONS, inspectDocument } from "./documents.validation.js"; import type { DocumentLimits, DocumentList, DocumentRecord, DocumentsHealth, RemovalResult } from "./documents.types.js"; @@ -61,6 +62,7 @@ export class DocumentsService { private readonly events: DocumentEventPublisher = new HttpDocumentEventPublisher(), private readonly projects: ProjectLookup = new ProjectsRepository(), private readonly maxBytes: number = Math.floor(env.DOCUMENT_MAX_SIZE_MB * 1024 * 1024), + private readonly ingestion: DocumentIngestionClient = new HttpDocumentIngestionClient(), ) {} get limits(): DocumentLimits { @@ -212,12 +214,38 @@ export class DocumentsService { } } + async retryProcessing(projetoId: string, documentoId: string): Promise { + const project = await this.requireProject(projetoId); + if (project.status === "arquivado") throw new ArchiveConflict("Projeto arquivado é somente leitura e não permite reprocessar documentos."); + validateUuid(documentoId, "ID do documento"); + if (!(await this.repository.retryIngestion(projetoId, documentoId))) { + const document = await this.repository.findById(projetoId, documentoId); + if (!document) throw new NotFoundError("Documento não encontrado neste projeto."); + if (document.status_processamento === "pendente" || document.status_processamento === "processando") return; + throw new ValidationError("Somente documentos com falha podem ser processados novamente."); + } + } + + async processPendingDocuments(): Promise { + const pending = await this.repository.claimPendingIngestion(1); + for (const document of pending) { + try { + const content = await this.storage.read(document.caminho); + const chunks = await this.ingestion.process(document, content); + await this.repository.completeIngestion(document, chunks); + } catch (error) { + const reason = error instanceof AppError ? error.message : "Falha interna ao processar documento."; + await this.repository.failIngestion(document.id, document.projeto_id, document.processamento_lease_id, reason).catch(() => undefined); + } + } + } + async reconcileStagedFiles(): Promise { await this.storage.reconcileStaged((key) => this.repository.documentExists(key)); } async runBackgroundMaintenance(): Promise { - await Promise.all([this.flushPendingEvents(), this.processPendingStorageOperations()]); + await Promise.all([this.flushPendingEvents(), this.processPendingStorageOperations(), this.processPendingDocuments()]); await this.reconcileStagedFiles(); } } @@ -231,7 +259,7 @@ export function startDocumentsBackgroundWorker(service: DocumentsService = docum if (running) return; running = true; try { - await Promise.all([service.flushPendingEvents(), service.processPendingStorageOperations()]); + await Promise.all([service.flushPendingEvents(), service.processPendingStorageOperations(), service.processPendingDocuments()]); if (Date.now() - lastReconcile >= 5 * 60_000) { await service.reconcileStagedFiles(); lastReconcile = Date.now(); diff --git a/backend/src/modules/documents/documents.storage.ts b/backend/src/modules/documents/documents.storage.ts index bb5f12f..59c6e01 100644 --- a/backend/src/modules/documents/documents.storage.ts +++ b/backend/src/modules/documents/documents.storage.ts @@ -1,4 +1,4 @@ -import { access, mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { access, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; const STORAGE_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; @@ -7,6 +7,7 @@ const UPLOAD_SUFFIX = ".uploading"; export interface DocumentStorage { save(key: string, content: Buffer): Promise; + read(key: string): Promise; finalizeUpload(key: string): Promise; remove(key: string): Promise; stageRemoval(key: string): Promise; @@ -57,6 +58,10 @@ export class LocalDocumentStorage implements DocumentStorage { } } + async read(key: string): Promise { + return readFile(this.pathOf(key)); + } + async remove(key: string): Promise { const path = this.pathOf(key); await Promise.all([ diff --git a/backend/src/modules/documents/documents.types.ts b/backend/src/modules/documents/documents.types.ts index 9fedd0b..9c8e5d5 100644 --- a/backend/src/modules/documents/documents.types.ts +++ b/backend/src/modules/documents/documents.types.ts @@ -12,6 +12,8 @@ export interface DocumentRecord { mime: string | null; tamanho_bytes: number | null; status_processamento: DocumentStatus; + processamento_erro?: string | null; + processamento_tentativas?: number; armazenamento_pendente: boolean; autor_id: string | null; autor_nome: string | null; @@ -50,6 +52,20 @@ export interface StoredDocument { status_processamento: DocumentStatus; } +export interface PendingIngestionDocument extends StoredDocument { + mime: string | null; + extensao: string | null; + /** Fencing token: only the worker holding this lease may commit its result. */ + processamento_lease_id: string; +} + +export interface ExtractedDocumentChunk { + chunk_index: number; + text: string; + embedding: number[]; + metadata: Record; +} + export interface RemovalResult { documento: StoredDocument; indexado: boolean; diff --git a/backend/src/modules/search/search.repository.db.test.ts b/backend/src/modules/search/search.repository.db.test.ts new file mode 100644 index 0000000..5003ac2 --- /dev/null +++ b/backend/src/modules/search/search.repository.db.test.ts @@ -0,0 +1,93 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { Pool } from "pg"; +import { validateTarget } from "../../database/seed-lib.js"; +import { SearchRepository } from "./search.repository.js"; + +const databaseUrl = process.env.HYBRID_SEARCH_TEST_DATABASE_URL; + +test("S2-06: busca híbrida aplica escopo antes da fusão e combina filtros no PostgreSQL", { skip: !databaseUrl }, async () => { + const db = new Pool({ max: 1, connectionString: validateTarget(databaseUrl, "test") }); + const schema = `hybrid_search_${randomUUID().replaceAll("-", "")}`; + const migration = await readFile(resolve(process.cwd(), "../database/migrations/014_hybrid_search.sql"), "utf8"); + const projectA = randomUUID(); + const projectB = randomUUID(); + const documentA = randomUUID(); + const documentB = randomUUID(); + const documentC = randomUUID(); + const chunkA = randomUUID(); + const chunkB = randomUUID(); + const chunkC = randomUUID(); + const technologyId = randomUUID(); + const vector = Array.from({ length: 1024 }, (_, index) => index === 0 ? 1 : 0); + const vectorLiteral = `[${vector.join(",")}]`; + + try { + await db.query("CREATE EXTENSION IF NOT EXISTS vector"); + await db.query(`CREATE SCHEMA ${schema}`); + await db.query(`SET search_path TO ${schema}, public`); + await db.query("CREATE TABLE projeto (id uuid PRIMARY KEY, nome text NOT NULL)"); + await db.query("CREATE TABLE documento (id uuid PRIMARY KEY, nome text NOT NULL)"); + await db.query("CREATE TABLE decisao (id uuid PRIMARY KEY, titulo text, entidade_tipo text, entidade_id uuid)"); + await db.query("CREATE TABLE epico (id uuid PRIMARY KEY, titulo text)"); + await db.query("CREATE TABLE feature (id uuid PRIMARY KEY, titulo text, epico_id uuid)"); + await db.query("CREATE TABLE pbi (id uuid PRIMARY KEY, titulo text, feature_id uuid)"); + await db.query("CREATE TABLE tecnologia (id uuid PRIMARY KEY, nome text)"); + await db.query("CREATE TABLE entidade_tecnologia (id uuid PRIMARY KEY, entidade_tipo text NOT NULL, entidade_id uuid NOT NULL, tecnologia_id uuid NOT NULL)"); + await db.query("CREATE TABLE chunk (id uuid PRIMARY KEY, projeto_id uuid NOT NULL, entidade_tipo text NOT NULL, entidade_id uuid NOT NULL, texto text NOT NULL, metadados_json jsonb DEFAULT '{}'::jsonb, embedding vector(1024))"); + await db.query(migration); + await db.query(migration); + const index = await db.query<{ indexname: string }>( + "SELECT indexname FROM pg_indexes WHERE schemaname = $1 AND tablename = 'chunk' AND indexname = 'idx_chunk_text_search_portuguese'", + [schema], + ); + assert.equal(index.rowCount, 1, "a migration cria o índice textual e pode ser repetida"); + await db.query("INSERT INTO projeto VALUES ($1,'Projeto A'),($2,'Projeto B')", [projectA, projectB]); + await db.query("INSERT INTO documento VALUES ($1,'Acesso'),($2,'Outro projeto'),($3,'Documento sem nível')", [documentA, documentB, documentC]); + await db.query("INSERT INTO tecnologia VALUES ($1,'Autenticação')", [technologyId]); + await db.query("INSERT INTO entidade_tecnologia VALUES ($1,'documento',$2,$3)", [randomUUID(), documentA, technologyId]); + await db.query( + `INSERT INTO chunk (id,projeto_id,entidade_tipo,entidade_id,texto,metadados_json,embedding) VALUES + ($1,$2,'documento',$3,'Acesso administrativo ao portal e controle de permissões','{"source_locator":"GRF-01"}',$4::vector), + ($5,$6,'documento',$7,'login de cliente; conteúdo confidencial externo','{"source_locator":"GRF-08"}',$4::vector), + ($8,$2,'documento',$9,'Registro de uma decisão de arquitetura','{"tecnologias_ids":[]}',NULL)`, + [chunkA, projectA, documentA, vectorLiteral, chunkB, projectB, documentB, chunkC, documentC], + ); + + const repository = new SearchRepository(db); + assert.equal(await repository.projectExists(projectA), true); + const input = { query: "login de cliente", projectId: projectA, limit: 10 }; + const scoped = await repository.hybridSearch(input, vector, 0.3); + assert.deepEqual(scoped.map((row) => row.id), [chunkA], "resultado semântico é limitado ao projeto A"); + assert.equal(scoped[0].project_id, projectA); + assert.equal(scoped[0].source_url, `/projects/${projectA}/documents`, "resultado de documento leva à aba do documento de origem"); + + const tagged = await repository.hybridSearch({ ...input, technologyId }, vector, 0.3); + assert.deepEqual(tagged.map((row) => row.id), [chunkA], "filtro de tecnologia funciona junto com o escopo de projeto"); + + const combined = await repository.hybridSearch({ ...input, technologyId, level: "documento" }, vector, 0.3); + assert.deepEqual(combined.map((row) => row.id), [chunkA], "tecnologia, nível e projeto são intersectados"); + + const wrongTechnology = await repository.hybridSearch({ ...input, technologyId: randomUUID() }, vector, 0.3); + assert.deepEqual(wrongTechnology, []); + + const wrongLevel = await repository.hybridSearch({ ...input, level: "pbi" }, vector, 0.3); + assert.deepEqual(wrongLevel, []); + + const exactIdentifier = await repository.hybridSearch({ ...input, query: "GRF-01" }, vector, 0.3); + assert.deepEqual(exactIdentifier.map((row) => row.id), [chunkA], "o localizador exato é pesquisável e continua limitado ao projeto"); + const lowercaseIdentifier = await repository.hybridSearch({ ...input, query: "grf-01" }, vector, 0.3); + assert.deepEqual(lowercaseIdentifier.map((row) => row.id), [chunkA], "identificadores existentes não diferenciam maiúsculas de minúsculas"); + const missingLowercaseIdentifier = await repository.hybridSearch({ ...input, query: "grf-99" }, vector, 0.3); + assert.deepEqual(missingLowercaseIdentifier, [], "identificador inexistente em minúsculas não cai no ranking semântico"); + const exactIdentifierOtherProject = await repository.hybridSearch({ ...input, query: "GRF-08", projectId: projectA }, vector, 0.3); + assert.deepEqual(exactIdentifierOtherProject, [], "localizador de outro projeto não vaza por sinal lexical exato"); + } finally { + await db.query("RESET search_path").catch(() => undefined); + await db.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`).catch(() => undefined); + await db.end(); + } +}); diff --git a/backend/src/modules/search/search.repository.test.ts b/backend/src/modules/search/search.repository.test.ts new file mode 100644 index 0000000..d8e3bc0 --- /dev/null +++ b/backend/src/modules/search/search.repository.test.ts @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { Pool } from "pg"; +import { SearchRepository } from "./search.repository.js"; + +const PROJECT = "60000000-0000-4000-8000-000000000001"; + +test("full-text e busca vetorial recebem o mesmo escopo de projeto e filtros combinados", async () => { + let sql = ""; + let params: unknown[] = []; + const repo = new SearchRepository({ + async query(statement: string, values: unknown[]) { + sql = statement; + params = values; + return { rows: [] }; + }, + } as unknown as Pool); + await repo.hybridSearch({ + query: "login de cliente", projectId: PROJECT, + technologyId: "60000000-0000-4000-8000-000000000099", level: "pbi", limit: 7, + }, [0.1, 0.2], 0.35, 0.12); + + assert.match(sql, /WHERE c\.projeto_id = \$1/); + assert.match(sql, /websearch_to_tsquery\('portuguese', \$2\)/); + assert.match(sql, /lower\(metadados_json->>'source_locator'\) = lower\(btrim\(\$2\)\)/); + assert.match(sql, /upper\(btrim\(\$2\)\) ~ '\^\[A-Z\]\{2,8\}-\[0-9\]\{1,6\}\$'/); + assert.match(sql, /metadata_locator_match\s+OR \(NOT \(SELECT is_identifier_query FROM exact_identifier\)\s+AND search_vector @@ websearch_to_tsquery\('portuguese', \$2\)\)/); + assert.match(sql, /NOT \(SELECT is_identifier_query FROM exact_identifier\)/); + assert.match(sql, /search_vector/); + assert.match(sql, /embedding <=> \$3::vector/); + assert.match(sql, /et\.tecnologia_id = \$4/); + assert.match(sql, /c\.entidade_tipo = \$5/); + assert.match(sql, /WHERE rank >= \$8 OR rank = 10\.0/); + assert.match(sql, /1 - distance >= \$7/); + assert.deepEqual(params.slice(0, 2), [PROJECT, "login de cliente"]); + assert.equal(params[2], "[0.1,0.2]"); + assert.equal(params[3], "60000000-0000-4000-8000-000000000099"); + assert.equal(params[4], "pbi"); + assert.equal(params[5], 7); + assert.equal(params[6], 0.35); + assert.equal(params[7], 0.12); +}); + +test("a busca devolve apenas linhas ordenadas produzidas pela fusão do banco", async () => { + const row = { + id: "chunk-1", project_id: PROJECT, project_name: "API 1", entity_type: "documento", + entity_id: "document-1", title: "Acesso", text: "Login", metadata: {}, source_url: null, relevance_score: 0.03, + }; + const repo = new SearchRepository({ + async query() { return { rows: [row] }; }, + } as unknown as Pool); + assert.deepEqual(await repo.hybridSearch({ query: "login", projectId: PROJECT, limit: 5 }, [0.1], 0.3), [row]); +}); diff --git a/backend/src/modules/search/search.repository.ts b/backend/src/modules/search/search.repository.ts new file mode 100644 index 0000000..0edd0a8 --- /dev/null +++ b/backend/src/modules/search/search.repository.ts @@ -0,0 +1,131 @@ +import { Pool } from "pg"; +import { pool } from "../../database/db.js"; +import type { HybridSearchInput, HybridSearchRow } from "./search.types.js"; + +export class SearchRepository { + private readonly pool: Pool; + + constructor(customPool?: Pool) { + this.pool = customPool ?? pool; + } + + async projectExists(projectId: string): Promise { + const result = await this.pool.query("SELECT 1 FROM projeto WHERE id = $1", [projectId]); + return (result.rowCount ?? 0) > 0; + } + + async hybridSearch(input: HybridSearchInput, embedding: number[], minimumSimilarity: number, minimumTextRank = 0.05): Promise { + const vector = `[${embedding.join(",")}]`; + const result = await this.pool.query( + `WITH scoped_chunks AS ( + SELECT c.id, c.projeto_id, c.entidade_tipo, c.entidade_id, c.texto, + c.metadados_json, c.embedding, p.nome AS projeto_nome + FROM chunk c + JOIN projeto p ON p.id = c.projeto_id + WHERE c.projeto_id = $1 + AND ($4::uuid IS NULL OR EXISTS ( + SELECT 1 + FROM entidade_tecnologia et + WHERE et.tecnologia_id = $4 + AND ( + (et.entidade_tipo = c.entidade_tipo AND et.entidade_id = c.entidade_id) + OR (c.entidade_tipo = 'decisao' AND EXISTS ( + SELECT 1 FROM decisao d + WHERE d.id = c.entidade_id + AND d.entidade_tipo = et.entidade_tipo + AND d.entidade_id = et.entidade_id + )) + OR c.metadados_json->'tecnologias_ids' ? $4::text + ) + )) + AND ($5::text IS NULL OR c.entidade_tipo = $5) + ), + exact_identifier AS ( + SELECT EXISTS ( + SELECT 1 FROM scoped_chunks + WHERE COALESCE(metadados_json->>'source_locator', '') <> '' + 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 + ), + 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 + ELSE ts_rank_cd(search_vector, websearch_to_tsquery('portuguese', $2)) END AS rank + FROM scoped_chunks + CROSS JOIN LATERAL ( + SELECT to_tsvector('portuguese', texto) AS search_vector, + COALESCE(metadados_json->>'source_locator', '') <> '' + AND lower(metadados_json->>'source_locator') = lower(btrim($2)) AS metadata_locator_match + ) signals + WHERE metadata_locator_match + OR (NOT (SELECT is_identifier_query FROM exact_identifier) + AND search_vector @@ websearch_to_tsquery('portuguese', $2)) + + ORDER BY rank DESC, id + LIMIT $6 + ) candidates + WHERE rank >= $8 OR rank = 10.0 + ), + semantic AS ( + SELECT id, row_number() OVER (ORDER BY distance ASC, id) AS position + FROM ( + SELECT id, embedding <=> $3::vector AS distance + FROM scoped_chunks + WHERE embedding IS NOT NULL + AND NOT (SELECT is_identifier_query FROM exact_identifier) + ORDER BY embedding <=> $3::vector, id + LIMIT $6 + ) candidates + WHERE 1 - distance >= $7 + ), + fused AS ( + SELECT id, SUM(score)::double precision AS relevance_score + FROM ( + SELECT id, 1.0 / (60 + position) AS score FROM lexical + UNION ALL + SELECT id, 1.0 / (60 + position) AS score FROM semantic + ) signals + GROUP BY id + ) + SELECT c.id, c.projeto_id AS project_id, c.projeto_nome AS project_name, + c.entidade_tipo AS entity_type, c.entidade_id AS entity_id, + COALESCE(doc.nome, dec.titulo, e.titulo, f.titulo, b.titulo) AS title, + c.texto AS text, c.metadados_json AS metadata, + CASE c.entidade_tipo + WHEN 'documento' THEN '/projects/' || c.projeto_id::text || '/documents' + WHEN 'epico' THEN '/projects/' || c.projeto_id::text || '/epics/' || c.entidade_id::text + WHEN 'feature' THEN '/projects/' || c.projeto_id::text || '/epics/' || f.epico_id::text || '/features/' || c.entidade_id::text + WHEN 'pbi' THEN '/projects/' || c.projeto_id::text || '/epics/' || source_pbi_feature.epico_id::text + || '/features/' || b.feature_id::text || '/pbis/' || c.entidade_id::text + WHEN 'decisao' THEN CASE dec.entidade_tipo + WHEN 'projeto' THEN '/projects/' || c.projeto_id::text + WHEN 'epico' THEN '/projects/' || c.projeto_id::text || '/epics/' || dec.entidade_id::text + WHEN 'feature' THEN '/projects/' || c.projeto_id::text || '/epics/' || source_decision_feature.epico_id::text + || '/features/' || dec.entidade_id::text + WHEN 'pbi' THEN '/projects/' || c.projeto_id::text || '/epics/' || source_decision_pbi_feature.epico_id::text + || '/features/' || source_decision_pbi.feature_id::text || '/pbis/' || dec.entidade_id::text + ELSE NULL + END + ELSE NULL + END AS source_url, + fused.relevance_score + FROM fused + JOIN scoped_chunks 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 + LEFT JOIN feature f ON c.entidade_tipo = 'feature' AND f.id = c.entidade_id + LEFT JOIN pbi b ON c.entidade_tipo = 'pbi' AND b.id = c.entidade_id + LEFT JOIN feature source_pbi_feature ON c.entidade_tipo = 'pbi' AND source_pbi_feature.id = b.feature_id + LEFT JOIN feature source_decision_feature ON c.entidade_tipo = 'decisao' AND dec.entidade_tipo = 'feature' AND source_decision_feature.id = dec.entidade_id + LEFT JOIN pbi source_decision_pbi ON c.entidade_tipo = 'decisao' AND dec.entidade_tipo = 'pbi' AND source_decision_pbi.id = dec.entidade_id + 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`, + [input.projectId, input.query, vector, input.technologyId ?? null, input.level ?? null, input.limit, minimumSimilarity, minimumTextRank], + ); + return result.rows; + } +} diff --git a/backend/src/modules/search/search.routes.test.ts b/backend/src/modules/search/search.routes.test.ts new file mode 100644 index 0000000..08e5a8f --- /dev/null +++ b/backend/src/modules/search/search.routes.test.ts @@ -0,0 +1,69 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import express, { type RequestHandler } from "express"; +import { AddressInfo } from "node:net"; +import { errorHandler } from "../../middleware/errorHandler.js"; +import { createSearchRouter } from "./search.routes.js"; +import type { SearchService } from "./search.service.js"; +import type { HybridSearchInput } from "./search.types.js"; + +const PROJECT = "60000000-0000-4000-8000-000000000001"; +const TECH = "60000000-0000-4000-8000-000000000099"; + +async function withServer(handler: RequestHandler, service: SearchService, run: (url: string) => Promise) { + const app = express(); + app.use("/api/v1/search", createSearchRouter(service, handler)); + app.use(errorHandler); + const server = app.listen(0, "127.0.0.1"); + try { + await new Promise((resolve) => server.once("listening", resolve)); + const address = server.address() as AddressInfo; + await run(`http://127.0.0.1:${address.port}/api/v1/search`); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +} + +test("exige texto e projeto válido e rejeita filtros inválidos", async () => { + const calls: HybridSearchInput[] = []; + const service = { async search(input: HybridSearchInput) { calls.push(input); return { items: [], total: 0 }; } } as unknown as SearchService; + await withServer((_req, _res, next) => next(), service, async (url) => { + assert.equal((await fetch(url)).status, 400); + assert.equal((await fetch(`${url}?q=ab&projeto_id=${PROJECT}`)).status, 400); + assert.equal((await fetch(`${url}?q=login`)).status, 400); + assert.equal((await fetch(`${url}?q=login&projeto_id=not-uuid`)).status, 400); + assert.equal((await fetch(`${url}?q=login&projeto_id=${PROJECT}&nivel=admin`)).status, 400); + assert.equal((await fetch(`${url}?q=login&projeto_id=${PROJECT}&limit=1.5`)).status, 400); + assert.equal((await fetch(`${url}?q=login&projeto_id=${PROJECT}&limit=1e1`)).status, 400); + }); + assert.deepEqual(calls, []); +}); + +test("aceita aliases documentados e encaminha filtros combinados ao serviço", async () => { + const calls: HybridSearchInput[] = []; + const service = { async search(input: HybridSearchInput) { calls.push(input); return { items: [], total: 0, query: input.query }; } } as unknown as SearchService; + await withServer((_req, _res, next) => next(), service, async (url) => { + const response = await fetch(`${url}?q=login%20de%20cliente&projectId=${PROJECT}&technology_id=${TECH}&level=pbi&limit=7`); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { items: [], total: 0, query: "login de cliente" }); + }); + assert.deepEqual(calls, [{ + query: "login de cliente", projectId: PROJECT, technologyId: TECH, level: "pbi", limit: 7, + }]); +}); + +test("a rota de produção mantém autenticação obrigatória", async () => { + const service = { async search() { throw new Error("não deve executar sem sessão"); } } as unknown as SearchService; + const app = express(); + app.use("/api/v1/search", createSearchRouter(service)); + app.use(errorHandler); + const server = app.listen(0, "127.0.0.1"); + try { + await new Promise((resolve) => server.once("listening", resolve)); + const address = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${address.port}/api/v1/search?q=login&projeto_id=${PROJECT}`); + assert.equal(response.status, 401); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); diff --git a/backend/src/modules/search/search.routes.ts b/backend/src/modules/search/search.routes.ts index c08008a..9c3ef01 100644 --- a/backend/src/modules/search/search.routes.ts +++ b/backend/src/modules/search/search.routes.ts @@ -1,58 +1,48 @@ -import { Router, Request, Response, NextFunction } from "express"; -import { ValidationError, validateUuid } from "../../shared/errors.js"; -import { pool } from "../../database/db.js"; +import { Router, Request, Response, NextFunction, type RequestHandler } from "express"; import { requireAuth } from "../../middleware/requireAuth.js"; - -export const searchRouter = Router(); - -searchRouter.use(requireAuth); - -// GET /api/v1/search?q=...&projeto_id=... -searchRouter.get("/", async (req: Request, res: Response, next: NextFunction) => { - try { - const q = ((req.query.q as string) || "").trim(); - const projectId = (req.query.projeto_id as string) || (req.query.projectId as string); - - if (projectId) validateUuid(projectId, "ID do projeto"); - if (q.length > 200) throw new ValidationError("A busca pode ter no máximo 200 caracteres."); - - if (!q) { - // Se a query estiver vazia, retorna os chunks mais recentes - let sql = ` - SELECT c.id, c.projeto_id, c.entidade_tipo, c.entidade_id, c.texto, c.metadados_json, c.created_at, p.nome as projeto_nome - FROM chunk c - JOIN projeto p ON c.projeto_id = p.id - `; - const params: unknown[] = []; - if (projectId) { - sql += " WHERE c.projeto_id = $1"; - params.push(projectId); - } - sql += " ORDER BY c.created_at DESC LIMIT 50"; - const result = await pool.query(sql, params); - res.json({ items: result.rows, total: result.rowCount }); - return; - } - - // Busca textual / ilike sobre a tabela chunk com escopo por projeto (RNF-03) - let sql = ` - SELECT c.id, c.projeto_id, c.entidade_tipo, c.entidade_id, c.texto, c.metadados_json, c.created_at, p.nome as projeto_nome - FROM chunk c - JOIN projeto p ON c.projeto_id = p.id - WHERE c.texto ILIKE $1 - `; - const params: unknown[] = [`%${q.replace(/[\\%_]/g, "\\$&")}%`]; - - if (projectId) { - sql += " AND c.projeto_id = $2"; - params.push(projectId); +import { ValidationError, validateUuid } from "../../shared/errors.js"; +import { parseSearchLevel, SearchService } from "./search.service.js"; + +const textQuery = (value: unknown, field: string): string | undefined => { + if (value === undefined) return undefined; + if (typeof value !== "string") throw new ValidationError(`${field} deve ser texto.`); + return value; +}; + +export function createSearchRouter(service: SearchService = new SearchService(), auth: RequestHandler = requireAuth) { + const router = Router(); + router.use(auth); + + router.get("/", async (req: Request, res: Response, next: NextFunction) => { + try { + const query = textQuery(req.query.q, "A busca"); + const projectId = textQuery(req.query.projeto_id ?? req.query.projectId, "ID do projeto"); + const technologyId = textQuery(req.query.tecnologia_id ?? req.query.technology_id, "ID da tecnologia"); + const levelValue = textQuery(req.query.nivel ?? req.query.level, "Nível"); + const limitValue = textQuery(req.query.limit, "Limite"); + + if (!query?.trim()) throw new ValidationError("Informe o texto da busca."); + if (query.trim().length < 3 || query.trim().length > 200) throw new ValidationError("A busca deve ter entre 3 e 200 caracteres."); + if (!projectId) throw new ValidationError("Informe o projeto para manter o escopo da busca."); + validateUuid(projectId, "ID do projeto"); + if (technologyId) validateUuid(technologyId, "ID da tecnologia"); + const parsedLimit = limitValue === undefined ? 10 : (/^(?:[1-9]|[1-4]\d|50)$/.test(limitValue) ? Number(limitValue) : Number.NaN); + if (!Number.isInteger(parsedLimit)) throw new ValidationError("O limite deve ser um inteiro entre 1 e 50."); + + const result = await service.search({ + query, + projectId, + technologyId, + level: parseSearchLevel(levelValue), + limit: parsedLimit, + }); + res.json(result); + } catch (error) { + next(error); } + }); - sql += " ORDER BY c.created_at DESC LIMIT 50"; + return router; +} - const result = await pool.query(sql, params); - res.json({ items: result.rows, total: result.rowCount }); - } catch (error) { - next(error); - } -}); +export const searchRouter = createSearchRouter(); diff --git a/backend/src/modules/search/search.service.test.ts b/backend/src/modules/search/search.service.test.ts new file mode 100644 index 0000000..165b68c --- /dev/null +++ b/backend/src/modules/search/search.service.test.ts @@ -0,0 +1,117 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { AppError, NotFoundError, ValidationError } from "../../shared/errors.js"; +import axios from "axios"; +import type { SearchRepository } from "./search.repository.js"; +import { SearchService } from "./search.service.js"; +import type { HybridSearchInput, HybridSearchRow } from "./search.types.js"; + +const PROJECT = "60000000-0000-4000-8000-000000000001"; +const TECH = "60000000-0000-4000-8000-000000000099"; +const VECTOR: number[] = Array.from({ length: 1024 }, (_, index) => index === 0 ? 1 : 0); + +function createService(overrides: { exists?: boolean; vector?: number[]; failEmbedding?: boolean } = {}) { + const calls: { input?: HybridSearchInput; vector?: number[]; threshold?: number; textThreshold?: number; embeddings: string[] } = { embeddings: [] }; + const rows: HybridSearchRow[] = []; + const repository = { + projectExists: async () => overrides.exists ?? true, + hybridSearch: async (input: HybridSearchInput, vector: number[], threshold: number, textThreshold: number) => { + calls.input = input; + calls.vector = vector; + calls.threshold = threshold; + calls.textThreshold = textThreshold; + return rows; + }, + } as unknown as SearchRepository; + const embeddings = { + embed: async (query: string) => { + calls.embeddings.push(query); + if (overrides.failEmbedding) throw new AppError("offline", 503, "EMBEDDING_SERVICE_UNAVAILABLE"); + return overrides.vector ?? VECTOR; + }, + }; + return { service: new SearchService(repository, embeddings, 0.37, 0.12), calls, rows }; +} + +const input: HybridSearchInput = { query: "login de cliente", projectId: PROJECT, limit: 5 }; + +test("cliente HTTP envia o campo esperado pelo FastAPI e valida erros remotos", async () => { + const originalPost = axios.post; + const calls: unknown[][] = []; + axios.post = (async (...args: unknown[]) => { + calls.push(args); + return { data: { embedding: VECTOR } }; + }) as typeof axios.post; + try { + const client = new (await import("./search.service.js")).HttpSearchEmbeddingClient("http://ai-service/"); + assert.deepEqual(await client.embed("consulta"), VECTOR); + assert.deepEqual(calls[0].slice(0, 2), ["http://ai-service/embeddings", { text: "consulta" }]); + + axios.post = (async () => { throw { isAxiosError: true, response: { status: 422 } }; }) as typeof axios.post; + await assert.rejects(client.embed("consulta"), (error: unknown) => error instanceof AppError && error.statusCode === 502); + + axios.post = (async () => { throw new Error("network down"); }) as typeof axios.post; + await assert.rejects(client.embed("consulta"), (error: unknown) => error instanceof AppError && error.statusCode === 503); + } finally { + axios.post = originalPost; + } +}); + +test("valida busca, escopo obrigatório, filtros e limite antes de acessar a IA", async () => { + const { service, calls } = createService(); + await assert.rejects(service.search({ ...input, query: " " }), ValidationError); + await assert.rejects(service.search({ ...input, query: "ab" }), ValidationError); + await assert.rejects(service.search({ ...input, projectId: "not-a-uuid" }), ValidationError); + await assert.rejects(service.search({ ...input, technologyId: "not-a-uuid" }), ValidationError); + await assert.rejects(service.search({ ...input, level: "admin" as never }), ValidationError); + await assert.rejects(service.search({ ...input, limit: 51 }), ValidationError); + assert.deepEqual(calls.embeddings, []); +}); + +test("confirma o projeto e solicita embedding para a mesma consulta antes do retrieval", async () => { + const { service, calls, rows } = createService(); + rows.push({ + id: "chunk-1", project_id: PROJECT, project_name: "API 1", entity_type: "documento", + entity_id: "document-1", title: "Acesso", text: "Login administrativo", metadata: {}, + source_url: null, relevance_score: 0.03, + }); + const result = await service.search({ ...input, query: " login de cliente ", technologyId: TECH, level: "documento" }); + + assert.deepEqual(calls.embeddings, ["login de cliente"]); + assert.equal(calls.input?.projectId, PROJECT); + assert.equal(calls.input?.technologyId, TECH); + assert.equal(calls.input?.level, "documento"); + assert.equal(calls.input?.limit, 5); + assert.deepEqual(calls.vector, VECTOR); + assert.equal(calls.threshold, 0.37); + assert.equal(calls.textThreshold, 0.12); + assert.equal(result.items[0].id, "chunk-1"); + assert.equal(result.project_id, PROJECT); + assert.ok(result.metrics.latency_ms >= 0); +}); + +test("projeto inexistente não chama embeddings", async () => { + const { service, calls } = createService({ exists: false }); + await assert.rejects(service.search(input), NotFoundError); + assert.deepEqual(calls.embeddings, []); +}); + +test("rejeita vetor com dimensão ou valores inválidos", async () => { + const { service: wrongDimension } = createService({ vector: [1, 2] }); + await assert.rejects(wrongDimension.search(input), (error: unknown) => error instanceof AppError && error.code === "INVALID_EMBEDDING"); + const badVector = [...VECTOR]; + badVector[10] = Number.NaN; + const { service: nonFinite } = createService({ vector: badVector }); + await assert.rejects(nonFinite.search(input), (error: unknown) => error instanceof AppError && error.code === "INVALID_EMBEDDING"); + const { service: zeroVector } = createService({ vector: Array(1024).fill(0) }); + await assert.rejects(zeroVector.search(input), (error: unknown) => error instanceof AppError && error.code === "INVALID_EMBEDDING"); + const nonNumericVector = [...VECTOR] as unknown as (number | string)[]; + nonNumericVector[4] = "1"; + const { service: nonNumeric } = createService({ vector: nonNumericVector as number[] }); + await assert.rejects(nonNumeric.search(input), (error: unknown) => error instanceof AppError && error.code === "INVALID_EMBEDDING"); +}); + +test("propaga indisponibilidade do serviço local sem degradar silenciosamente para busca textual", async () => { + const { service } = createService({ failEmbedding: true }); + await assert.rejects(service.search(input), (error: unknown) => error instanceof AppError && error.statusCode === 503); +}); diff --git a/backend/src/modules/search/search.service.ts b/backend/src/modules/search/search.service.ts new file mode 100644 index 0000000..99d1949 --- /dev/null +++ b/backend/src/modules/search/search.service.ts @@ -0,0 +1,69 @@ +import axios from "axios"; +import { env } from "../../config/env.js"; +import { AppError, NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; +import { SearchRepository } from "./search.repository.js"; +import { SEARCH_LEVELS, type HybridSearchInput, type HybridSearchResult, type SearchLevel } from "./search.types.js"; + +export interface SearchEmbeddingClient { + embed(text: string): Promise; +} + +export class HttpSearchEmbeddingClient implements SearchEmbeddingClient { + constructor(private readonly baseUrl = env.AI_SERVICE_URL) {} + + async embed(text: string): Promise { + try { + const response = await axios.post(`${this.baseUrl.replace(/\/$/, "")}/embeddings`, { text }, { timeout: 10_000 }); + return response.data?.embedding; + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + throw new AppError("O serviço local de embeddings rejeitou a consulta.", 502, "EMBEDDING_SERVICE_ERROR"); + } + throw new AppError("O serviço local de embeddings está indisponível.", 503, "EMBEDDING_SERVICE_UNAVAILABLE"); + } + } +} + +export class SearchService { + constructor( + private readonly repository: SearchRepository = new SearchRepository(), + private readonly embeddings: SearchEmbeddingClient = new HttpSearchEmbeddingClient(), + private readonly minimumSimilarity = env.SEARCH_MIN_VECTOR_SIMILARITY, + private readonly minimumTextRank = env.SEARCH_MIN_TEXT_RANK, + ) {} + + async search(input: HybridSearchInput): Promise { + const query = input.query.trim(); + if (query.length < 3) throw new ValidationError("A busca deve ter pelo menos 3 caracteres."); + if (query.length > 200) throw new ValidationError("A busca pode ter no máximo 200 caracteres."); + validateUuid(input.projectId, "ID do projeto"); + if (input.technologyId) validateUuid(input.technologyId, "ID da tecnologia"); + if (input.level && !SEARCH_LEVELS.includes(input.level)) throw new ValidationError("Nível de busca inválido."); + if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 50) { + throw new ValidationError("O limite deve ser um inteiro entre 1 e 50."); + } + if (!(await this.repository.projectExists(input.projectId))) throw new NotFoundError("Projeto não encontrado."); + + const startedAt = performance.now(); + const vector = await this.embeddings.embed(query); + if (!Array.isArray(vector) || vector.length !== 1024 || vector.some((value) => typeof value !== "number" || !Number.isFinite(value)) || vector.every((value) => value === 0)) { + throw new AppError("O serviço de embeddings retornou um vetor incompatível.", 502, "INVALID_EMBEDDING"); + } + + const items = await this.repository.hybridSearch({ ...input, query }, vector, this.minimumSimilarity, this.minimumTextRank); + return { + items, + total: items.length, + query, + project_id: input.projectId, + filters: { technology_id: input.technologyId ?? null, level: input.level ?? null }, + metrics: { latency_ms: Math.round(performance.now() - startedAt) }, + }; + } +} + +export function parseSearchLevel(value: string | undefined): SearchLevel | undefined { + if (!value) return undefined; + if (!SEARCH_LEVELS.includes(value as SearchLevel)) throw new ValidationError("Nível de busca inválido."); + return value as SearchLevel; +} diff --git a/backend/src/modules/search/search.types.ts b/backend/src/modules/search/search.types.ts new file mode 100644 index 0000000..76920ff --- /dev/null +++ b/backend/src/modules/search/search.types.ts @@ -0,0 +1,32 @@ +export const SEARCH_LEVELS = ["documento", "decisao", "epico", "feature", "pbi"] as const; +export type SearchLevel = typeof SEARCH_LEVELS[number]; + +export interface HybridSearchInput { + query: string; + projectId: string; + technologyId?: string; + level?: SearchLevel; + limit: number; +} + +export interface HybridSearchRow { + id: string; + project_id: string; + project_name: string; + entity_type: SearchLevel; + entity_id: string; + title: string | null; + text: string; + metadata: Record; + source_url: string | null; + relevance_score: number; +} + +export interface HybridSearchResult { + items: HybridSearchRow[]; + total: number; + query: string; + project_id: string; + filters: { technology_id: string | null; level: SearchLevel | null }; + metrics: { latency_ms: number }; +} diff --git a/database/init.sql b/database/init.sql index c372611..0df6d30 100644 --- a/database/init.sql +++ b/database/init.sql @@ -103,6 +103,11 @@ CREATE TABLE IF NOT EXISTS documento ( mime VARCHAR(100), caminho VARCHAR(500) NOT NULL, status_processamento VARCHAR(50) DEFAULT 'pendente', + processamento_tentativas INTEGER NOT NULL DEFAULT 0, + processamento_proxima_tentativa TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + processamento_bloqueado_ate TIMESTAMPTZ, + processamento_lease_id UUID, + processamento_erro VARCHAR(300), created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); @@ -227,4 +232,9 @@ CREATE INDEX IF NOT EXISTS idx_analise_repositorio_created_at ON analise_reposit -- Índice HNSW no pgvector para busca por similaridade de cosseno ultrarrápida CREATE INDEX IF NOT EXISTS idx_chunk_embedding ON chunk USING hnsw (embedding vector_cosine_ops); +CREATE INDEX IF NOT EXISTS idx_chunk_text_search_portuguese + ON chunk USING GIN (to_tsvector('portuguese', texto)); +CREATE INDEX IF NOT EXISTS idx_documento_ingestao_pendente + ON documento(processamento_proxima_tentativa, created_at) + WHERE status_processamento IN ('pendente', 'processando'); diff --git a/database/migrations/014_hybrid_search.sql b/database/migrations/014_hybrid_search.sql new file mode 100644 index 0000000..46addf5 --- /dev/null +++ b/database/migrations/014_hybrid_search.sql @@ -0,0 +1,3 @@ +-- S2-06: speed up Portuguese full-text candidate retrieval for the hybrid search. +CREATE INDEX IF NOT EXISTS idx_chunk_text_search_portuguese + ON chunk USING GIN (to_tsvector('portuguese', texto)); diff --git a/database/migrations/015_document_ingestion.sql b/database/migrations/015_document_ingestion.sql new file mode 100644 index 0000000..3f70ef5 --- /dev/null +++ b/database/migrations/015_document_ingestion.sql @@ -0,0 +1,10 @@ +-- S2-01/S2-02: durable ingestion leases, retry schedule and safe diagnostics. +ALTER TABLE documento + ADD COLUMN IF NOT EXISTS processamento_tentativas INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS processamento_proxima_tentativa TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + ADD COLUMN IF NOT EXISTS processamento_bloqueado_ate TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS processamento_erro VARCHAR(300); + +CREATE INDEX IF NOT EXISTS idx_documento_ingestao_pendente + ON documento(processamento_proxima_tentativa, created_at) + WHERE status_processamento IN ('pendente', 'processando'); diff --git a/database/migrations/017_document_ingestion_lease_fencing.sql b/database/migrations/017_document_ingestion_lease_fencing.sql new file mode 100644 index 0000000..8d90e4e --- /dev/null +++ b/database/migrations/017_document_ingestion_lease_fencing.sql @@ -0,0 +1,3 @@ +-- S2-01: fence stale workers after an ingestion lease expires and is reclaimed. +ALTER TABLE documento + ADD COLUMN IF NOT EXISTS processamento_lease_id UUID; diff --git a/database/seed/README.md b/database/seed/README.md index 102f214..29c1088 100644 --- a/database/seed/README.md +++ b/database/seed/README.md @@ -2,8 +2,9 @@ Este diretório contém dois conjuntos com propósitos diferentes: -1. `fixtures/historical-v1.json` e `curated/` formam o **acervo histórico curado PRE-06**. A carga é aplicada pelo backend, a partir de fontes acadêmicas documentadas, em projetos, documentos, chunks sem embedding e registros de origem/auditoria. -2. `dev_seed.sql` é um seed legado com dados fictícios determinísticos para demonstrações. Ele não faz parte da carga PRE-06 e não deve ser aplicado junto dela. +1. `fixtures/historical-v1.json` e `curated/` formam o **acervo histórico curado PRE-06 v1**. +2. `fixtures/historical-v2.json` e `curated/v2/` preservam os textos da v1 em IDs isolados e acrescentam `GRF-01`/`GRF-08` como metadados pesquisáveis. A v1 continua intacta. V2 é opt-in com `SEED_DATASET_VERSION=2`; sem essa variável, a ferramenta continua usando v1. +3. `dev_seed.sql` é um seed legado com dados fictícios determinísticos para demonstrações. Ele não faz parte da carga PRE-06 e não deve ser aplicado junto dela. Não inclua nomes, e-mails, credenciais, documentos de clientes, dados de produção ou segredos em qualquer fixture. @@ -28,6 +29,8 @@ O build é necessário porque os scripts `seed:validate` e `test:seed` executam 4. Confirme que `POSTGRES_*` do comando de migration aponta para o mesmo destino. 5. Execute `npm run seed:apply` dentro de `backend`. +Para validar ou aplicar explicitamente a versão 2, configure `SEED_DATASET_VERSION=2` no ambiente do processo. A versão padrão permanece v1. + O alvo precisa usar PostgreSQL/PostgresQL e o nome do banco deve terminar em `_dev` ou `_test`. O ambiente `production` é recusado, salvo a exceção explícita de segurança prevista no código; **não use a exceção para dados de demonstração**. A aplicação é transacional e idempotente: repetir os mesmos dados não duplica registros. Colisões ou divergência de conteúdo/origem abortam a operação; o seed não atualiza nem remove conteúdo preexistente. diff --git a/database/seed/curated/v2/api-1-backlog-23.md b/database/seed/curated/v2/api-1-backlog-23.md new file mode 100644 index 0000000..d03f7c0 --- /dev/null +++ b/database/seed/curated/v2/api-1-backlog-23.md @@ -0,0 +1,9 @@ +# Acesso administrativo + +Identificador de origem: Backlog 23 + +O backlog prevê login para administradores, restringindo o acesso às funcionalidades destinadas aos responsáveis. + +Origem: https://github.com/Galaticos-API/API-1/blob/1edd3096e46cb1cb9c794dbaec887a83487c0e97/readme.md#L68 + +Classificação: resumo curado de documentação pública. Não comprova implementação em produção nem experiência individual. diff --git a/database/seed/curated/v2/api-1-backlog-35.md b/database/seed/curated/v2/api-1-backlog-35.md new file mode 100644 index 0000000..fcb1195 --- /dev/null +++ b/database/seed/curated/v2/api-1-backlog-35.md @@ -0,0 +1,9 @@ +# Métricas de equipe em gráficos + +Identificador de origem: Backlog 35 + +O backlog prevê gráficos de avaliação para apoiar a análise do desempenho de equipes. + +Origem: https://github.com/Galaticos-API/API-1/blob/1edd3096e46cb1cb9c794dbaec887a83487c0e97/readme.md#L74 + +Classificação: resumo curado de documentação pública. Não comprova implementação em produção nem experiência individual. diff --git a/database/seed/curated/v2/api-2-us-01.md b/database/seed/curated/v2/api-2-us-01.md new file mode 100644 index 0000000..823fc35 --- /dev/null +++ b/database/seed/curated/v2/api-2-us-01.md @@ -0,0 +1,9 @@ +# Administrar acesso de usuários + +Identificador de origem: US-01 + +A documentação da primeira sprint descreve cadastro, consulta, edição e inativação de usuários pelo RH para administrar o acesso à plataforma. + +Origem: https://github.com/Galaticos-API/API-2/blob/e568684bc66a8a339a04a1f091d9599754d21cf6/DOCS/Documenta%C3%A7%C3%A3o%20das%20Sprints/DocSprint1.md#L43 + +Classificação: resumo curado de documentação pública. Não comprova implementação em produção nem experiência individual. diff --git a/database/seed/curated/v2/api-2-us-02.md b/database/seed/curated/v2/api-2-us-02.md new file mode 100644 index 0000000..80cf46f --- /dev/null +++ b/database/seed/curated/v2/api-2-us-02.md @@ -0,0 +1,9 @@ +# Registrar plano individual por ano + +Identificador de origem: US-02 + +A documentação descreve criação de um plano de desenvolvimento individual associado a um colaborador e a um ano, preservando o histórico de planos. + +Origem: https://github.com/Galaticos-API/API-2/blob/e568684bc66a8a339a04a1f091d9599754d21cf6/DOCS/Documenta%C3%A7%C3%A3o%20das%20Sprints/DocSprint1.md#L44 + +Classificação: resumo curado de documentação pública. Não comprova implementação em produção nem experiência individual. diff --git a/database/seed/curated/v2/api-3-grf-01.md b/database/seed/curated/v2/api-3-grf-01.md new file mode 100644 index 0000000..8cf62dd --- /dev/null +++ b/database/seed/curated/v2/api-3-grf-01.md @@ -0,0 +1,9 @@ +# Visualizar saldo de crédito nacional + +Identificador de origem: GRF-01 + +A análise de requisitos prevê gráfico de linha do saldo nacional de crédito, usando as séries 20539, 20540 e 20541. + +Origem: https://github.com/Galaticos-API/API-3/blob/34f1cc21825f9c392dcd776d6567fba2288b2e81/DOCS/analise_backend/analiseRequisitosBackend.md#L6 + +Classificação: resumo curado de documentação pública. Não comprova implementação em produção nem experiência individual. diff --git a/database/seed/curated/v2/api-3-grf-08.md b/database/seed/curated/v2/api-3-grf-08.md new file mode 100644 index 0000000..ad6d794 --- /dev/null +++ b/database/seed/curated/v2/api-3-grf-08.md @@ -0,0 +1,9 @@ +# Comparar oportunidades por estado + +Identificador de origem: GRF-08 + +A análise de requisitos prevê gráfico de barras do índice de oportunidade, com pontuação entre zero e dez e ordenação por estado. + +Origem: https://github.com/Galaticos-API/API-3/blob/34f1cc21825f9c392dcd776d6567fba2288b2e81/DOCS/analise_backend/analiseRequisitosBackend.md#L13 + +Classificação: resumo curado de documentação pública. Não comprova implementação em produção nem experiência individual. diff --git a/database/seed/fixtures/historical-v2.json b/database/seed/fixtures/historical-v2.json new file mode 100644 index 0000000..fd85714 --- /dev/null +++ b/database/seed/fixtures/historical-v2.json @@ -0,0 +1,389 @@ +{ + "dataset": "pre06-historical-v2", + "version": 2, + "source_decision": { + "selected_by": "Decisão do time registrada pelo usuário desta tarefa", + "decision": "Manter localizadores pesquisáveis e consultas por conteúdo na avaliação; versão 2 derivada da v1 sem modificá-la.", + "date": "2026-10-02", + "review": "pending_pull_request" + }, + "records": [ + { + "table": "projeto", + "values": { + "id": "62000000-0000-4000-8000-000000000001", + "nome": "PRE06 — Acervo API-1", + "cliente": "Organização não identificada", + "descricao": "Recorte documental curado do projeto API-1; não é cópia integral nem avaliação de pessoas.", + "status": "ativo" + }, + "source": { + "repository": "https://github.com/Galaticos-API/API-1", + "revision": "1edd3096e46cb1cb9c794dbaec887a83487c0e97", + "path": "readme.md", + "source_sha256": "a1591b42d9f7c266e6be7cfec43bf0ec06164c56ee8d22933dedb3a4c227a37c", + "classification": "curated-public-documentation", + "transformation": "Resumo em português; dados pessoais, autores, contatos, clientes e anexos excluídos. V2: localizador de origem preservado como metadado pesquisável; texto curado mantido.", + "source_kind": "historical", + "locator": "Backlog 23", + "url": "https://github.com/Galaticos-API/API-1/blob/1edd3096e46cb1cb9c794dbaec887a83487c0e97/readme.md#L68" + } + }, + { + "table": "documento", + "values": { + "id": "62000000-0000-4000-8000-000000000002", + "projeto_id": "62000000-0000-4000-8000-000000000001", + "nome": "API-1 — Acesso administrativo", + "mime": "text/markdown", + "caminho": "database/seed/curated/v2/api-1-backlog-23.md", + "status_processamento": "pendente" + }, + "source": { + "repository": "https://github.com/Galaticos-API/API-1", + "revision": "1edd3096e46cb1cb9c794dbaec887a83487c0e97", + "path": "readme.md", + "source_sha256": "a1591b42d9f7c266e6be7cfec43bf0ec06164c56ee8d22933dedb3a4c227a37c", + "classification": "curated-public-documentation", + "transformation": "Resumo em português; dados pessoais, autores, contatos, clientes e anexos excluídos. V2: localizador de origem preservado como metadado pesquisável; texto curado mantido.", + "source_kind": "historical", + "locator": "Backlog 23", + "url": "https://github.com/Galaticos-API/API-1/blob/1edd3096e46cb1cb9c794dbaec887a83487c0e97/readme.md#L68" + } + }, + { + "table": "chunk", + "values": { + "id": "62000000-0000-4000-8000-000000000003", + "projeto_id": "62000000-0000-4000-8000-000000000001", + "entidade_tipo": "documento", + "entidade_id": "62000000-0000-4000-8000-000000000002", + "texto": "O backlog prevê login para administradores, restringindo o acesso às funcionalidades destinadas aos responsáveis.", + "metadados_json": { + "dataset": "pre06-historical-v2", + "source_url": "https://github.com/Galaticos-API/API-1/blob/1edd3096e46cb1cb9c794dbaec887a83487c0e97/readme.md#L68", + "source_revision": "1edd3096e46cb1cb9c794dbaec887a83487c0e97", + "source_sha256": "a1591b42d9f7c266e6be7cfec43bf0ec06164c56ee8d22933dedb3a4c227a37c", + "classification": "curated-public-documentation", + "embedding_status": "pending", + "individual_experience": "not_evidenced" + } + }, + "source": { + "repository": "https://github.com/Galaticos-API/API-1", + "revision": "1edd3096e46cb1cb9c794dbaec887a83487c0e97", + "path": "readme.md", + "source_sha256": "a1591b42d9f7c266e6be7cfec43bf0ec06164c56ee8d22933dedb3a4c227a37c", + "classification": "curated-public-documentation", + "transformation": "Resumo em português; dados pessoais, autores, contatos, clientes e anexos excluídos. V2: localizador de origem preservado como metadado pesquisável; texto curado mantido.", + "source_kind": "historical", + "locator": "Backlog 23", + "url": "https://github.com/Galaticos-API/API-1/blob/1edd3096e46cb1cb9c794dbaec887a83487c0e97/readme.md#L68" + } + }, + { + "table": "documento", + "values": { + "id": "62000000-0000-4000-8000-000000000004", + "projeto_id": "62000000-0000-4000-8000-000000000001", + "nome": "API-1 — Métricas de equipe em gráficos", + "mime": "text/markdown", + "caminho": "database/seed/curated/v2/api-1-backlog-35.md", + "status_processamento": "pendente" + }, + "source": { + "repository": "https://github.com/Galaticos-API/API-1", + "revision": "1edd3096e46cb1cb9c794dbaec887a83487c0e97", + "path": "readme.md", + "source_sha256": "a1591b42d9f7c266e6be7cfec43bf0ec06164c56ee8d22933dedb3a4c227a37c", + "classification": "curated-public-documentation", + "transformation": "Resumo em português; dados pessoais, autores, contatos, clientes e anexos excluídos. V2: localizador de origem preservado como metadado pesquisável; texto curado mantido.", + "source_kind": "historical", + "locator": "Backlog 35", + "url": "https://github.com/Galaticos-API/API-1/blob/1edd3096e46cb1cb9c794dbaec887a83487c0e97/readme.md#L74" + } + }, + { + "table": "chunk", + "values": { + "id": "62000000-0000-4000-8000-000000000005", + "projeto_id": "62000000-0000-4000-8000-000000000001", + "entidade_tipo": "documento", + "entidade_id": "62000000-0000-4000-8000-000000000004", + "texto": "O backlog prevê gráficos de avaliação para apoiar a análise do desempenho de equipes.", + "metadados_json": { + "dataset": "pre06-historical-v2", + "source_url": "https://github.com/Galaticos-API/API-1/blob/1edd3096e46cb1cb9c794dbaec887a83487c0e97/readme.md#L74", + "source_revision": "1edd3096e46cb1cb9c794dbaec887a83487c0e97", + "source_sha256": "a1591b42d9f7c266e6be7cfec43bf0ec06164c56ee8d22933dedb3a4c227a37c", + "classification": "curated-public-documentation", + "embedding_status": "pending", + "individual_experience": "not_evidenced" + } + }, + "source": { + "repository": "https://github.com/Galaticos-API/API-1", + "revision": "1edd3096e46cb1cb9c794dbaec887a83487c0e97", + "path": "readme.md", + "source_sha256": "a1591b42d9f7c266e6be7cfec43bf0ec06164c56ee8d22933dedb3a4c227a37c", + "classification": "curated-public-documentation", + "transformation": "Resumo em português; dados pessoais, autores, contatos, clientes e anexos excluídos. V2: localizador de origem preservado como metadado pesquisável; texto curado mantido.", + "source_kind": "historical", + "locator": "Backlog 35", + "url": "https://github.com/Galaticos-API/API-1/blob/1edd3096e46cb1cb9c794dbaec887a83487c0e97/readme.md#L74" + } + }, + { + "table": "projeto", + "values": { + "id": "62000000-0000-4000-8000-000000000006", + "nome": "PRE06 — Acervo API-2", + "cliente": "Organização não identificada", + "descricao": "Recorte documental curado do projeto API-2; não é cópia integral nem avaliação de pessoas.", + "status": "ativo" + }, + "source": { + "repository": "https://github.com/Galaticos-API/API-2", + "revision": "e568684bc66a8a339a04a1f091d9599754d21cf6", + "path": "DOCS/Documentação das Sprints/DocSprint1.md", + "source_sha256": "b64e0422210a7799af4519a3a0d21406920ef46169a1b3424888544ba584150c", + "classification": "curated-public-documentation", + "transformation": "Resumo em português; dados pessoais, autores, contatos, clientes e anexos excluídos. V2: localizador de origem preservado como metadado pesquisável; texto curado mantido.", + "source_kind": "historical", + "locator": "US-01", + "url": "https://github.com/Galaticos-API/API-2/blob/e568684bc66a8a339a04a1f091d9599754d21cf6/DOCS/Documenta%C3%A7%C3%A3o%20das%20Sprints/DocSprint1.md#L43" + } + }, + { + "table": "documento", + "values": { + "id": "62000000-0000-4000-8000-000000000007", + "projeto_id": "62000000-0000-4000-8000-000000000006", + "nome": "API-2 — Administrar acesso de usuários", + "mime": "text/markdown", + "caminho": "database/seed/curated/v2/api-2-us-01.md", + "status_processamento": "pendente" + }, + "source": { + "repository": "https://github.com/Galaticos-API/API-2", + "revision": "e568684bc66a8a339a04a1f091d9599754d21cf6", + "path": "DOCS/Documentação das Sprints/DocSprint1.md", + "source_sha256": "b64e0422210a7799af4519a3a0d21406920ef46169a1b3424888544ba584150c", + "classification": "curated-public-documentation", + "transformation": "Resumo em português; dados pessoais, autores, contatos, clientes e anexos excluídos. V2: localizador de origem preservado como metadado pesquisável; texto curado mantido.", + "source_kind": "historical", + "locator": "US-01", + "url": "https://github.com/Galaticos-API/API-2/blob/e568684bc66a8a339a04a1f091d9599754d21cf6/DOCS/Documenta%C3%A7%C3%A3o%20das%20Sprints/DocSprint1.md#L43" + } + }, + { + "table": "chunk", + "values": { + "id": "62000000-0000-4000-8000-000000000008", + "projeto_id": "62000000-0000-4000-8000-000000000006", + "entidade_tipo": "documento", + "entidade_id": "62000000-0000-4000-8000-000000000007", + "texto": "A documentação da primeira sprint descreve cadastro, consulta, edição e inativação de usuários pelo RH para administrar o acesso à plataforma.", + "metadados_json": { + "dataset": "pre06-historical-v2", + "source_url": "https://github.com/Galaticos-API/API-2/blob/e568684bc66a8a339a04a1f091d9599754d21cf6/DOCS/Documenta%C3%A7%C3%A3o%20das%20Sprints/DocSprint1.md#L43", + "source_revision": "e568684bc66a8a339a04a1f091d9599754d21cf6", + "source_sha256": "b64e0422210a7799af4519a3a0d21406920ef46169a1b3424888544ba584150c", + "classification": "curated-public-documentation", + "embedding_status": "pending", + "individual_experience": "not_evidenced" + } + }, + "source": { + "repository": "https://github.com/Galaticos-API/API-2", + "revision": "e568684bc66a8a339a04a1f091d9599754d21cf6", + "path": "DOCS/Documentação das Sprints/DocSprint1.md", + "source_sha256": "b64e0422210a7799af4519a3a0d21406920ef46169a1b3424888544ba584150c", + "classification": "curated-public-documentation", + "transformation": "Resumo em português; dados pessoais, autores, contatos, clientes e anexos excluídos. V2: localizador de origem preservado como metadado pesquisável; texto curado mantido.", + "source_kind": "historical", + "locator": "US-01", + "url": "https://github.com/Galaticos-API/API-2/blob/e568684bc66a8a339a04a1f091d9599754d21cf6/DOCS/Documenta%C3%A7%C3%A3o%20das%20Sprints/DocSprint1.md#L43" + } + }, + { + "table": "documento", + "values": { + "id": "62000000-0000-4000-8000-000000000009", + "projeto_id": "62000000-0000-4000-8000-000000000006", + "nome": "API-2 — Registrar plano individual por ano", + "mime": "text/markdown", + "caminho": "database/seed/curated/v2/api-2-us-02.md", + "status_processamento": "pendente" + }, + "source": { + "repository": "https://github.com/Galaticos-API/API-2", + "revision": "e568684bc66a8a339a04a1f091d9599754d21cf6", + "path": "DOCS/Documentação das Sprints/DocSprint1.md", + "source_sha256": "b64e0422210a7799af4519a3a0d21406920ef46169a1b3424888544ba584150c", + "classification": "curated-public-documentation", + "transformation": "Resumo em português; dados pessoais, autores, contatos, clientes e anexos excluídos. V2: localizador de origem preservado como metadado pesquisável; texto curado mantido.", + "source_kind": "historical", + "locator": "US-02", + "url": "https://github.com/Galaticos-API/API-2/blob/e568684bc66a8a339a04a1f091d9599754d21cf6/DOCS/Documenta%C3%A7%C3%A3o%20das%20Sprints/DocSprint1.md#L44" + } + }, + { + "table": "chunk", + "values": { + "id": "62000000-0000-4000-8000-000000000010", + "projeto_id": "62000000-0000-4000-8000-000000000006", + "entidade_tipo": "documento", + "entidade_id": "62000000-0000-4000-8000-000000000009", + "texto": "A documentação descreve criação de um plano de desenvolvimento individual associado a um colaborador e a um ano, preservando o histórico de planos.", + "metadados_json": { + "dataset": "pre06-historical-v2", + "source_url": "https://github.com/Galaticos-API/API-2/blob/e568684bc66a8a339a04a1f091d9599754d21cf6/DOCS/Documenta%C3%A7%C3%A3o%20das%20Sprints/DocSprint1.md#L44", + "source_revision": "e568684bc66a8a339a04a1f091d9599754d21cf6", + "source_sha256": "b64e0422210a7799af4519a3a0d21406920ef46169a1b3424888544ba584150c", + "classification": "curated-public-documentation", + "embedding_status": "pending", + "individual_experience": "not_evidenced" + } + }, + "source": { + "repository": "https://github.com/Galaticos-API/API-2", + "revision": "e568684bc66a8a339a04a1f091d9599754d21cf6", + "path": "DOCS/Documentação das Sprints/DocSprint1.md", + "source_sha256": "b64e0422210a7799af4519a3a0d21406920ef46169a1b3424888544ba584150c", + "classification": "curated-public-documentation", + "transformation": "Resumo em português; dados pessoais, autores, contatos, clientes e anexos excluídos. V2: localizador de origem preservado como metadado pesquisável; texto curado mantido.", + "source_kind": "historical", + "locator": "US-02", + "url": "https://github.com/Galaticos-API/API-2/blob/e568684bc66a8a339a04a1f091d9599754d21cf6/DOCS/Documenta%C3%A7%C3%A3o%20das%20Sprints/DocSprint1.md#L44" + } + }, + { + "table": "projeto", + "values": { + "id": "62000000-0000-4000-8000-000000000011", + "nome": "PRE06 — Acervo API-3", + "cliente": "Organização não identificada", + "descricao": "Recorte documental curado do projeto API-3; não é cópia integral nem avaliação de pessoas.", + "status": "ativo" + }, + "source": { + "repository": "https://github.com/Galaticos-API/API-3", + "revision": "34f1cc21825f9c392dcd776d6567fba2288b2e81", + "path": "DOCS/analise_backend/analiseRequisitosBackend.md", + "source_sha256": "a608ea5aa562682ae2c236ede3eca07b092622602c03928a726347ffe5c1cf9b", + "classification": "curated-public-documentation", + "transformation": "Resumo em português; dados pessoais, autores, contatos, clientes e anexos excluídos. V2: localizador de origem preservado como metadado pesquisável; texto curado mantido.", + "source_kind": "historical", + "locator": "GRF-01", + "url": "https://github.com/Galaticos-API/API-3/blob/34f1cc21825f9c392dcd776d6567fba2288b2e81/DOCS/analise_backend/analiseRequisitosBackend.md#L6" + } + }, + { + "table": "documento", + "values": { + "id": "62000000-0000-4000-8000-000000000012", + "projeto_id": "62000000-0000-4000-8000-000000000011", + "nome": "API-3 — Visualizar saldo de crédito nacional", + "mime": "text/markdown", + "caminho": "database/seed/curated/v2/api-3-grf-01.md", + "status_processamento": "pendente" + }, + "source": { + "repository": "https://github.com/Galaticos-API/API-3", + "revision": "34f1cc21825f9c392dcd776d6567fba2288b2e81", + "path": "DOCS/analise_backend/analiseRequisitosBackend.md", + "source_sha256": "a608ea5aa562682ae2c236ede3eca07b092622602c03928a726347ffe5c1cf9b", + "classification": "curated-public-documentation", + "transformation": "Resumo em português; dados pessoais, autores, contatos, clientes e anexos excluídos. V2: localizador de origem preservado como metadado pesquisável; texto curado mantido.", + "source_kind": "historical", + "locator": "GRF-01", + "url": "https://github.com/Galaticos-API/API-3/blob/34f1cc21825f9c392dcd776d6567fba2288b2e81/DOCS/analise_backend/analiseRequisitosBackend.md#L6" + } + }, + { + "table": "chunk", + "values": { + "id": "62000000-0000-4000-8000-000000000013", + "projeto_id": "62000000-0000-4000-8000-000000000011", + "entidade_tipo": "documento", + "entidade_id": "62000000-0000-4000-8000-000000000012", + "texto": "A análise de requisitos prevê gráfico de linha do saldo nacional de crédito, usando as séries 20539, 20540 e 20541.", + "metadados_json": { + "dataset": "pre06-historical-v2", + "source_url": "https://github.com/Galaticos-API/API-3/blob/34f1cc21825f9c392dcd776d6567fba2288b2e81/DOCS/analise_backend/analiseRequisitosBackend.md#L6", + "source_revision": "34f1cc21825f9c392dcd776d6567fba2288b2e81", + "source_sha256": "a608ea5aa562682ae2c236ede3eca07b092622602c03928a726347ffe5c1cf9b", + "classification": "curated-public-documentation", + "embedding_status": "pending", + "individual_experience": "not_evidenced", + "source_locator": "GRF-01" + } + }, + "source": { + "repository": "https://github.com/Galaticos-API/API-3", + "revision": "34f1cc21825f9c392dcd776d6567fba2288b2e81", + "path": "DOCS/analise_backend/analiseRequisitosBackend.md", + "source_sha256": "a608ea5aa562682ae2c236ede3eca07b092622602c03928a726347ffe5c1cf9b", + "classification": "curated-public-documentation", + "transformation": "Resumo em português; dados pessoais, autores, contatos, clientes e anexos excluídos. V2: localizador de origem preservado como metadado pesquisável; texto curado mantido.", + "source_kind": "historical", + "locator": "GRF-01", + "url": "https://github.com/Galaticos-API/API-3/blob/34f1cc21825f9c392dcd776d6567fba2288b2e81/DOCS/analise_backend/analiseRequisitosBackend.md#L6" + } + }, + { + "table": "documento", + "values": { + "id": "62000000-0000-4000-8000-000000000014", + "projeto_id": "62000000-0000-4000-8000-000000000011", + "nome": "API-3 — Comparar oportunidades por estado", + "mime": "text/markdown", + "caminho": "database/seed/curated/v2/api-3-grf-08.md", + "status_processamento": "pendente" + }, + "source": { + "repository": "https://github.com/Galaticos-API/API-3", + "revision": "34f1cc21825f9c392dcd776d6567fba2288b2e81", + "path": "DOCS/analise_backend/analiseRequisitosBackend.md", + "source_sha256": "a608ea5aa562682ae2c236ede3eca07b092622602c03928a726347ffe5c1cf9b", + "classification": "curated-public-documentation", + "transformation": "Resumo em português; dados pessoais, autores, contatos, clientes e anexos excluídos. V2: localizador de origem preservado como metadado pesquisável; texto curado mantido.", + "source_kind": "historical", + "locator": "GRF-08", + "url": "https://github.com/Galaticos-API/API-3/blob/34f1cc21825f9c392dcd776d6567fba2288b2e81/DOCS/analise_backend/analiseRequisitosBackend.md#L13" + } + }, + { + "table": "chunk", + "values": { + "id": "62000000-0000-4000-8000-000000000015", + "projeto_id": "62000000-0000-4000-8000-000000000011", + "entidade_tipo": "documento", + "entidade_id": "62000000-0000-4000-8000-000000000014", + "texto": "A análise de requisitos prevê gráfico de barras do índice de oportunidade, com pontuação entre zero e dez e ordenação por estado.", + "metadados_json": { + "dataset": "pre06-historical-v2", + "source_url": "https://github.com/Galaticos-API/API-3/blob/34f1cc21825f9c392dcd776d6567fba2288b2e81/DOCS/analise_backend/analiseRequisitosBackend.md#L13", + "source_revision": "34f1cc21825f9c392dcd776d6567fba2288b2e81", + "source_sha256": "a608ea5aa562682ae2c236ede3eca07b092622602c03928a726347ffe5c1cf9b", + "classification": "curated-public-documentation", + "embedding_status": "pending", + "individual_experience": "not_evidenced", + "source_locator": "GRF-08" + } + }, + "source": { + "repository": "https://github.com/Galaticos-API/API-3", + "revision": "34f1cc21825f9c392dcd776d6567fba2288b2e81", + "path": "DOCS/analise_backend/analiseRequisitosBackend.md", + "source_sha256": "a608ea5aa562682ae2c236ede3eca07b092622602c03928a726347ffe5c1cf9b", + "classification": "curated-public-documentation", + "transformation": "Resumo em português; dados pessoais, autores, contatos, clientes e anexos excluídos. V2: localizador de origem preservado como metadado pesquisável; texto curado mantido.", + "source_kind": "historical", + "locator": "GRF-08", + "url": "https://github.com/Galaticos-API/API-3/blob/34f1cc21825f9c392dcd776d6567fba2288b2e81/DOCS/analise_backend/analiseRequisitosBackend.md#L13" + } + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml index cbb8515..b7facba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -103,9 +103,12 @@ services: # AI Service dentro da rede Docker - AI_SERVICE_URL=http://ai-service:8000 + - DOCUMENT_INGESTION_TOKEN=${DOCUMENT_INGESTION_TOKEN:?Defina um segredo aleatório privado em DOCUMENT_INGESTION_TOKEN no .env} - REPO_ANALYZER_URL=${REPO_ANALYZER_URL:-http://ai-service:8000} - DOCUMENT_MAX_SIZE_MB=${DOCUMENT_MAX_SIZE_MB:-20} + - SEARCH_MIN_VECTOR_SIMILARITY=${SEARCH_MIN_VECTOR_SIMILARITY:-0.55} + - SEARCH_MIN_TEXT_RANK=${SEARCH_MIN_TEXT_RANK:-0.05} - DOCUMENT_STORAGE_DIR=/data/documents - DOCUMENT_EVENTS_WEBHOOK_URL=${DOCUMENT_EVENTS_WEBHOOK_URL:-} - MIGRATIONS_DIR=/database/migrations @@ -135,9 +138,9 @@ services: restart: unless-stopped ports: - - "${AI_SERVICE_PORT:-8000}:8000" + - "${AI_SERVICE_BIND:-127.0.0.1}:${AI_SERVICE_PORT:-8000}:8000" - # Permite acesso ao Ollama no Windows + # O backend acessa pelo DNS interno do Compose; a porta no host fica local por padrão. extra_hosts: - "host.docker.internal:host-gateway" @@ -146,6 +149,8 @@ services: environment: - AI_SERVICE_PORT=8000 + - NODE_ENV=production + - DOCUMENT_INGESTION_TOKEN=${DOCUMENT_INGESTION_TOKEN:?Defina um segredo aleatório privado em DOCUMENT_INGESTION_TOKEN no .env} - AI_SERVICE_HOST=0.0.0.0 # Ollama no Windows @@ -211,4 +216,4 @@ volumes: networks: sinapse-network: name: sinapse-network - driver: bridge \ No newline at end of file + driver: bridge diff --git a/docs/DOCUMENTOS_INTEGRACAO.md b/docs/DOCUMENTOS_INTEGRACAO.md index c4f7ad6..ee9275b 100644 --- a/docs/DOCUMENTOS_INTEGRACAO.md +++ b/docs/DOCUMENTOS_INTEGRACAO.md @@ -1,23 +1,23 @@ # Documentos: upload, remoção, outbox e escopo (S1-19, S1-20, S1-22) -> Estado revisado em 27/09/2026. Este guia detalha o contrato de documentos da -> Sprint 1. O upload persiste o arquivo e seus metadados; extração, chunking e -> indexação não são prometidos como concluídos. Veja também a [arquitetura](Architecture/README.md) +> Pipeline de ingestão atualizado para S2-01/S2-02. Expurgo administrativo e indexação do backlog são entregas separadas. Veja também a [arquitetura](Architecture/README.md) > e a [referência da API](api/openapi.yaml). ## Escopo desta entrega x Sprint 2 -| Item | Entregue (Sprint 1) | Fica para a S2-01 | -|---|---|---| -| Upload seguro (PDF, DOCX, MD, TXT) com tipo real, tamanho, armazenamento e auditoria | Sim | — | -| Listagem por projeto com metadados, cursor e estado | Sim | — | -| Remoção confirmada, idempotente, com limpeza de metadados, arquivo e chunks | Sim | — | -| Evento `document.removed` gravado na mesma transação (outbox) e entregue por worker | Sim | — | -| Extração de texto, chunking, embeddings e transição `pendente → processando → processado/falha` | Não | Sim | -| Disponibilidade do conteúdo na busca e no chat | Não | Sim | +| Item | Estado | +|---|---| +| Upload seguro (PDF, DOCX, MD, TXT), armazenamento e auditoria | Implementado na S1 | +| Extração, chunking, embeddings locais e persistência transacional dos chunks | Implementado na S2-01/S2-02 | +| Estados `pendente → processando → processado/falha`, lease recuperável e erro visível | Implementado na S2-01 | +| Retry explícito de falhas | `POST /api/v1/projects/{projectId}/documents/{documentId}/retry` | +| Remoção do documento e chunks no escopo do projeto | Implementado na S1 | + +O backend reserva no máximo um documento por ciclo com `FOR UPDATE SKIP LOCKED` e lease de 30 minutos. Reinício do backend permite recuperar reservas expiradas. O backend lê o arquivo do storage, envia-o ao serviço local, valida escopo, chunks/vetores e persiste tudo numa transação; o serviço Python não grava no banco. A repetição substitui os chunks anteriores no mesmo commit, preservando projeto e documento nos metadados. A resposta é limitada a 500 chunks de até 1.000 caracteres cada e vetores de 1.024 dimensões; resposta com IDs/metadados divergentes é rejeitada. + +PDFs com camada textual são extraídos sem OCR; PDFs compostos apenas por imagens ficam sem texto indexável e são marcados como falha. Em DOCX, a extração percorre parágrafos e células de tabelas na ordem do corpo do documento. Limites contra arquivos excessivos: 20 MiB de conteúdo, 5.000 páginas/entradas DOCX, 100 MiB descompactados e 2 milhões de caracteres extraídos. -`pendente` significa **armazenado e aguardando ingestão**. A interface e o OpenAPI não prometem indexação. -S1-21 trata de protótipos de PBIs e não tem relação com a ingestão. +Falhas definidas de extração/vetorização mudam o estado para `falha` e armazenam uma mensagem genérica sem conteúdo do arquivo, caminho ou detalhes internos. A pessoa com permissão de escrita pode agendar novo processamento; o retry não cria tentativas concorrentes para documentos já processando. ## Fluxo de upload @@ -38,6 +38,8 @@ S1-21 trata de protótipos de PBIs e não tem relação com a ingestão. Iniciado com o backend (`startDocumentsBackgroundWorker`, a cada 15 s). Independe de qualquer `DELETE`. +- **Ingestão**: reserva um documento pronto para leitura, sem upload ainda pendente; faz extração/embedding fora da transação e grava chunks + estado `processado` atomicamente. Se o worker cair, a lease expira; se processamento falhar, o estado fica visível e o retry é manual. + - **Outbox** (`evento_integracao`): reserva em lote com `FOR UPDATE SKIP LOCKED` e lease de 1 minuto (duas instâncias não pegam o mesmo evento); falha aplica backoff exponencial (15 s até 1 h) e grava só uma mensagem genérica em `last_error`. - **Armazenamento** (`documento_operacao_armazenamento`): mesma reserva e backoff para finalizar uploads e descartar remoções. - **Reconciliação** a cada 5 min: arquivos `.uploading` e `.removing` só são tratados após período de graça de 5 min; com documento vinculado o arquivo é finalizado/restaurado, sem vínculo é descartado. @@ -58,9 +60,13 @@ Iniciado com o backend (`startDocumentsBackgroundWorker`, a cada 15 s). Independ | Variável | Padrão | Uso | |---|---|---| -| `DOCUMENT_MAX_SIZE_MB` | `20` | Limite por arquivo, aplicado no backend e informado em `limites.max_bytes` | +| `DOCUMENT_MAX_SIZE_MB` | `20` | Limite por arquivo, entre 0 e 20 MiB, aplicado no backend e informado em `limites.max_bytes` | | `DOCUMENT_STORAGE_DIR` | `storage/documents` | Diretório dos arquivos (volume `documents_data` no compose) | | `DOCUMENT_EVENTS_WEBHOOK_URL` | vazio | Webhook do consumidor de `document.removed` | +| `AI_SERVICE_URL` | `http://localhost:8000` | Serviço local Python para extração e embeddings | +| `DOCUMENT_INGESTION_TOKEN` | obrigatório no Compose | Segredo aleatório compartilhado entre Node e IA; gere ao menos 32 bytes de entropia e nunca use valor fixo/versionado | + +Não há token padrão de desenvolvimento. O Compose exige o segredo no `.env`; o backend e o serviço de IA também validam sua configuração em produção. A porta do serviço de IA publica somente em `127.0.0.1` por padrão. ## Contrato do consumidor de `document.removed` @@ -78,10 +84,28 @@ Os `chunk` no PostgreSQL já são removidos pelo backend na mesma transação; o ## Como validar +### Smoke ponta a ponta dos quatro formatos + +O smoke [`scripts/smoke_document_lifecycle.py`](../scripts/smoke_document_lifecycle.py) valida upload autenticado, worker, extração/chunks, busca com origem e remoção para PDF, DOCX, Markdown e TXT. Ele cria arquivos sintéticos e tenta remover cada documento, inclusive na saída por erro. Execute somente em um **projeto descartável** acessível à sessão; a remoção ainda cria registros normais de auditoria/outbox. + +No PowerShell, defina a sessão e o UUID do projeto descartável sem colocar credenciais na linha de comando ou no histórico: + +```powershell +$env:SINAPSE_SESSION_COOKIE = '' +$env:SINAPSE_PROJECT_ID = '' +python scripts/smoke_document_lifecycle.py --confirm-disposable +Remove-Item Env:SINAPSE_SESSION_COOKIE +Remove-Item Env:SINAPSE_PROJECT_ID +``` + +O script não imprime nem grava a sessão. Cada formato tem um prazo máximo de 10 minutos, configurável com `--timeout`; falha se processamento, busca ou remoção não confirmar o resultado esperado. + ```bash cd backend npm test npm run build +python -m unittest discover -s ../ai-service/tests -p "test_chunker.py" +python -m unittest discover -s ../ai-service/tests -p "test_document_ingestion.py" ``` Os testes de banco cobrem migração 012 (banco limpo e já na 011, idempotência e dados legados), corrida arquivamento x upload/remoção, lease e backoff da outbox, operações de armazenamento e paginação estável isolada por projeto. Para habilitar as suítes PostgreSQL, configure URLs de banco descartável documentadas no [guia de setup](SETUP_GUIDE.md); no PowerShell use `$env:NOME_DA_VARIAVEL = '...'`. Nunca aponte essas variáveis para uma base compartilhada ou produção. Os testes E2E de navegador estão em `e2e/` (ver [README E2E](../e2e/README.md)). diff --git a/docs/PLANO_FECHAMENTO_PR_UNICO_S2.md b/docs/PLANO_FECHAMENTO_PR_UNICO_S2.md new file mode 100644 index 0000000..b022660 --- /dev/null +++ b/docs/PLANO_FECHAMENTO_PR_UNICO_S2.md @@ -0,0 +1,230 @@ +# Plano detalhado de fechamento e PR único — S2-01, S2-02, S2-06 e S2-17 + +**Criado:** 02/10/2026 +**Revisão do plano:** 03/10/2026 +**Objetivo:** concluir e validar S2-01, S2-02, S2-06 e S2-17, depois enviá-las juntas em um único PR para `main`. +**Estado revisado em:** 03/10/2026. Branch `codex/s2-17-ptbr-search-evaluation`, baseada em `origin/main` `4dc3033`. PR de rascunho será aberto a pedido do usuário, com os gates de latência e relevância explicitamente reprovados; não representa aprovação para merge nem aceite Scrum. + +> **Nota da revisão:** este é o único plano operacional. `PLANO_CONTINUACAO_QA_S2.md` e os relatórios anteriores são registros históricos; não usar suas afirmações de aprovação quando divergirem deste documento e das evidências mais recentes. Os passos abaixo são gates de fechamento propostos, não uma redefinição automática do DoD do Trello. + +## 1. Escopo e dependências + +O planejamento do backlog identifica a seguinte dependência da avaliação S2-17: + +```text +S2-01 ─┐ +S2-02 ─┼──> avaliação final S2-17 +S2-06 ─┘ +``` + +S2-17 pode desenvolver harness, dataset e testes em paralelo, mas a execução que servirá de baseline final só pode ocorrer contra a implementação integrada de S2-01, S2-02 e S2-06. S2-01 e S2-02 formam o fluxo de ingestão; S2-06 implementa recuperação; S2-17 mede esse comportamento. + +S2-03/S2-04 e S2-05 **não são dependências formais deste plano** e permanecem fora deste PR. S2-08 também não é declarada concluída; entra somente a adaptação mínima de `KnowledgeView` necessária para continuar funcionando com o novo contrato de busca, evitando regressão de UI. A implementação de indexação automática de itens do backlog permanece fora do PR e é descrita como limitação de escopo. + +## 2. Estado de partida + +| Tarefa | O que já está comprovado | O que falta antes do PR único | +|---|---|---| +| S2-01 | Integração PostgreSQL de documentos aprovada 17/17; leases/fencing, falha, retry manual e ausência de duplicação cobertos; fluxo real concluiu ingestão | Revisão de pares e CI no SHA final; reinício do processo durante uma lease permanece cobertura adicional recomendada | +| S2-02 | Smoke autenticado real passou por PDF/DOCX/MD/TXT: upload→processamento→busca/origem→remoção; 60 testes Python | Revisão de pares e CI no SHA final | +| S2-06 | Integração PostgreSQL aprovada 1/1; smoke real encontrou e removeu as quatro fontes no projeto descartável | Relevância ainda não aprovada: Q008 perdido e Q022 falso positivo no corpus pequeno; metas de produto e baseline ampliada pendentes | +| S2-17 | Dataset v1 preservado, v2 com 26 consultas; harness com métricas por categoria/proveniência; 62 testes Python; baseline autenticado de 26 consultas executado em 03/10 | Gate de latência falhou (p95 2.261 ms; 0/26 abaixo de 2 s), Q008 ausente/Q022 falso positivo; otimizar/corroborar ambiente e acordar critérios de relevância | + +Registro de evidências de 02/10: [relatório final QA](RELATORIO_FINAL_QA_S2_2026-10-02.md). O diretório `tmp/` contém artefatos locais históricos e deve permanecer fora do PR. + +## 3. Critérios globais para um PR único + +O PR só será aberto quando: + +1. As alterações incluídas estiverem classificadas por S2-01, S2-02, S2-06 e S2-17, com dependências e arquivos compartilhados explicados. +2. O ciclo de documento sintético provar upload autenticado → processamento → chunk e vetor persistidos → busca com origem correta → remoção → fonte ausente em busca posterior. +3. PDF, DOCX, Markdown e TXT passarem por validação repetível de processamento/extração e ao menos uma fixture passar pelo ciclo API completo. Completar busca/remoção para os quatro é recomendado se o smoke permitir; PDF sem texto/OCR deve falhar claramente. +4. Nenhuma violação de projeto ocorrer; filtros combinados retornarem apenas fontes dentro do escopo; ausência e recuperação forem reportadas por categoria. +5. Resultado final de S2-17 identificar o SHA exato da implementação avaliada, configuração e hashes das versões de dataset/corpus. Se o relatório for commitado depois da execução, confirmar que nenhum código/configuração mudou; não exigir que o relatório contenha seu próprio SHA final. +6. Backend, IA e frontend passarem nas suites relevantes, typecheck/build, migrações PostgreSQL vazias e build Docker. +7. Revisão final não incluir `tmp/`, cookies, credenciais, bancos locais, dados de usuário ou mudanças de outras tasks sem justificativa. +8. O PR único descrever cada tarefa, dependências, testes, limitações e tradeoffs e receber revisão por par. + +Este plano não marca cartões do Trello automaticamente. O PR de rascunho é aberto por autorização explícita do usuário, apesar dos gates locais reprovados, para revisão e feedback; CI verde, revisão independente, correções e aceite Scrum continuam necessários antes de merge ou conclusão dos cartões. + +### Classificação dos gates + +- **Obrigatórios para o PR:** escopo rastreável, migrations completas e ordenadas, suites relevantes verdes, nenhum vazamento de projeto, dataset/relatório reproduzíveis, diff revisado e CI no PR. +- **Critérios de produto:** relevância mínima e comportamento de abstenção precisam vir do backlog ou ser acordados com PO/time antes de usar como bloqueio. `0.55` é provisório, não aceite. +- **Endurecimento QA recomendado:** repetição de corrida, casos adversariais adicionais e E2E expandido aumentam confiança. Se o DoD não exigir cada caso, registrar como cobertura adicional sem transformar automaticamente em novo escopo de sprint. +- **Higiene local:** temporários e storage de QA devem ser identificados e mantidos fora do PR. Limpeza não é critério funcional; não apagar arquivos sem comprovar que são descartáveis. + +## 4. Fase 0 — Congelar baseline, inventariar e separar o escopo + +### Passo a passo + +1. Registrar branch, SHA da base `4dc3033`, estado remoto atualizado (`git fetch` seguido de comparação com `origin/main`), `git status` e diff stat, sem resetar ou sobrescrever o working tree atual. +2. Fazer inventário arquivo a arquivo e atribuir cada alteração a S2-01/02, S2-06, S2-17, suporte compartilhado ou fora do escopo. +3. Marcar mudanças compartilhadas que exigem revisão por hunks: `backend/src/index.ts`, `backend/package.json`, `backend/src/config/env.ts`, `database/init.sql`, `docker-compose.yml`, `docs/api/openapi.yaml`, `backend/src/database/seed-lib.ts` e documentação. +4. Verificar se os workers/repositórios S2-03/04 e o expurgo S2-05 são tecnicamente obrigatórios para executar S2-06 ou S2-17. Usar imports/runtime/testes como evidência, não a proximidade dos arquivos. +5. Classificar migrations e dependências reais pelo histórico/registro de migrations. Nunca omitir uma migration necessária à sequência só por pertencer a outra task: incluir a cadeia indispensável e explicar o vínculo, ou demonstrar que ela não é requisito do conjunto candidato. Não reordenar nem reescrever migrations aplicadas. +6. Garantir que artefatos em `tmp/` não serão staged. Não abrir nem exibir credenciais/cookies. Manter os temporários fora do PR; limpeza é separada e só ocorre para artefatos cuja origem descartável esteja comprovada. **Concluído em 03/10:** verificada ausência de conexões e de bancos QA temporários no servidor PostgreSQL. +7. Definir um mapa de inclusão do PR e uma lista explícita de caminhos excluídos, sem editar mudanças preexistentes ainda não classificadas. + +### Saída + +- Matriz de paths → task → dependência. +- Escopo mínimo do PR decidido. +- Nenhum arquivo removido ou alteração existente descartada. + +## 5. Fase 1 — Fechar S2-01: pipeline e confiabilidade do worker + +### Passo a passo + +1. Criar banco PostgreSQL descartável novo, com nome terminado em `_test`, aplicar todas as migrations em ordem e guardar a saída sanitizada. +2. Rodar a integração de documentos isolada e confirmar `17/17` na revisão atual. +3. Cobrir transições `pendente → processando → processado/falha`, permissões de retry, documento de outro projeto e projeto arquivado. +4. Simular serviço IA indisponível após upload; confirmar estado `falha`, erro sem payload/segredo/caminho e ausência de chunks parciais. +5. Restaurar serviço IA, solicitar retry, acompanhar a conclusão e confirmar exatamente um conjunto de chunks. +6. Reiniciar worker com uma ingestão em andamento e comprovar retomada após expiração da lease. +7. Rodar dois workers simultâneos com documentos de fixture e verificar fencing: um único worker finaliza cada documento; uma lease antiga não pode sobrescrever a nova. +8. Conferir `health`, estados e retry na API/UI; mensagens devem explicar o estado sem exibir detalhe interno. +9. Repetir os testes de corrida em execuções independentes no banco descartável; três repetições são recomendação de confiança, não requisito de produto se o DoD não o exigir. +10. Revisar transações, logs, tratamento de exceção, storage temporário e isolamento entre projetos. + +### Critério S2-01 + +- Nenhum processamento fica preso após falha/reinício; nenhum chunk parcial ou duplicado. +- Fencing e concorrência passam repetidamente. +- Retry permitido apenas em falha e não contorna isolamento, permissão ou arquivamento. +- Nenhum segredo ou conteúdo de arquivo aparece em log/resposta de erro. + +## 6. Fase 2 — Fechar S2-02: extração, chunking e ciclo de documento + +### Passo a passo + +1. Preferir o comando de smoke/test harness já existente. Só criar novo script se o inventário provar que não há caminho repetível; evitar duplicar infraestrutura de teste. +2. Gerar um PDF com camada de texto, DOCX com parágrafos e tabela, Markdown e TXT; cada arquivo inclui uma frase exclusiva, sem conteúdo real de cliente. +3. Para cada arquivo, executar upload autenticado e registrar ID, status inicial e metadados, sem gravar cookie no resultado. +4. Esperar o worker finalizar com timeout finito; falhar o smoke se ultrapassar o timeout ou entrar em `falha`. +5. Consultar o banco para confirmar projeto/documento/origem, ordem de chunks, texto e dimensão vetorial 1024. +6. Buscar pela frase exclusiva com S2-06; verificar documento, projeto, link de origem e ausência de fontes de outro projeto. +7. Remover o documento pela API; confirmar remoção de chunks conforme contrato e nova busca sem a fonte removida. Cobrir o ciclo integral nos quatro formatos se o harness permitir sem custo excessivo; no mínimo, validar processamento e extração de cada formato e ciclo API integral em fixture representativa. +8. Adicionar testes negativos: arquivo vazio/corrompido, UTF-8 inválido, PDF criptografado/sem texto, DOCX inválido, extensão/MIME incompatível, mais de 20 MiB, texto acima do limite e mais de 500 chunks. Confirmar rejeição segura antes de chamar embeddings quando aplicável. +9. Testar chunking longo: cada trecho dentro do teto, texto final sem perda de cauda, sobreposição controlada, parágrafos respeitados e entrada sem espaços progride sem loop. +10. Testar contrato de resposta IA hostil/inválido: project/document ID divergente, chunk fora de ordem, texto acima do limite, embedding com dimensão errada/NaN e número excessivo de chunks. Confirmar que a persistência inteira é recusada. +11. Falhar uma persistência no meio e reprocessar; confirmar atomicidade e que os chunks antigos não se misturam aos novos. +12. Confirmar no guia que PDF sem OCR está fora do escopo e que limites backend/IA estão alinhados. + +### Critério S2-02 + +- PDF, DOCX, MD e TXT têm processamento e extração validados; o ciclo API busca/origem/remoção passa no smoke repetível. Estender o ciclo integral a cada formato é cobertura recomendada, salvo exigência explícita do cartão. +- Texto de tabela DOCX está presente; PDF sem texto não vira falso sucesso. +- Limites, dimensões, origem, idempotência e atomicidade têm testes reproduzíveis. + +## 7. Fase 3 — Corrigir e estabilizar S2-06 + +### Passo a passo + +1. Preservar datasets e fixtures v1/v2; não reescrever gabarito para transformar falso positivo em sucesso. +2. Expandir o corpus curado com mais documentos de pelo menos dois projetos e temas deliberadamente próximos (positivos e negativos). Toda fonte deve ter origem, projeto, tipo e revisão rastreáveis. +3. Publicar a expansão como nova versão de corpus/dataset; mapear todos os IDs e manter v1/v2 imutáveis. +4. Aumentar o conjunto negativo e de paráfrases; incluir consultas com assunto parecido em outro projeto, termos curtos, identificadores válidos/inexistentes, conteúdo com/sem evidência e filtros. +5. Instrumentar em modo de teste o sinal lexical, vetorial, exato e fusão, sem expor telemetria de debug ao endpoint público nem registrar credenciais. +6. Diagnosticar Q008 (PDI) e Q022 (saldo/gráfico) pela evidência-fonte e scores individuais. Separar erro de representação do corpus, embedding e ranking. +7. Comparar os cortes existentes incluindo `0.55`, baselines `0.30`/`0.60` e alternativas orientadas por sinal. Não subir o threshold sozinho; analisar qual sinal causou cada FP/FN. +8. Se a correção exigir regra nova (por exemplo, precedência de match exato ou política de abstenção por evidência), adicionar teste unitário e teste PostgreSQL antes de ajustar o dataset. +9. Validar cada cenário com escopo de projeto aplicado antes da fusão, filtros de tecnologia/nível combinados e nenhuma fonte cruzada. +10. Conferir navegação de resultados: link aponta para a entidade/projeto corretos; empty, erro, loading e retry são estados claros. +11. Usar como gates invariáveis: zero violações de projeto e limite de latência definido no backlog/contrato (documentar métrica e método). p95 <2 s só é gate se estiver definido assim no requisito; caso contrário, é meta operacional proposta. Reportar Recall, Precision, MRR, falsos positivos, falsos negativos e abstenção por categoria. +12. Registrar Q008/Q022 como testes de regressão e repetir a matriz em toda mudança de ranking. + +### Critério S2-06 + +- Busca exata por GRF e busca por conteúdo mantêm recuperação conforme gabarito curado. +- Filtros combinados e isolamento passam no PostgreSQL. +- Q008 e Q022 têm causa identificada e regressão classificada; correção não cria novos falsos positivos/falsos negativos silenciosos. +- Métricas e latência vêm do candidato final de código/corpus, não de uma execução antiga. + +## 8. Fase 4 — Fechar S2-17 e executar baseline final + +### Passo a passo + +1. Preservar avaliador, runner e datasets versionados; revisar novos casos do corpus com origem/gabarito antes de publicar. +2. Garantir validação de IDs duplicados/ausentes, versão, resultados faltantes/duplicados, projeto divergente, fonte desconhecida, projeto de fonte conhecido, latência inválida e filtros inconsistentes. +3. Garantir que runner use API autenticada real, registre round-trip e limiar efetivo e nunca imprima/guarde cookie, token, URL privada ou conteúdo fora da fixture aprovada. +4. Registrar revisão do código/corpus, modelo de embedding, valores de limiar, estado de aquecimento, hora, revisão Git, versão dataset/corpus e hardware disponível. Se CPU/RAM do servidor de inferência não puderem ser medidos, registrar “não medido”; não inferir a partir da máquina executora. +5. Reexecutar cada consulta uma vez para scoring e repetir ao menos 3 vezes um subconjunto de latência; incluir warm-up explicitamente. +6. Gerar relatório por categoria e global: Recall@5, Precision@5, MRR@5, abstenção, FP/FN, p50/p95, limite 2 s e isolamento. +7. Comparar com v1/v2 e registrar variação em pontos percentuais e IDs que mudaram; registrar o tradeoff de `0.55` ou do limiar que o substituir. +8. Executar avaliador sobre os resultados produzidos e fazer o runner falhar para qualquer violação de isolamento, consulta ausente, versão incompatível ou latência fora do gate de infraestrutura. +9. Salvar no repositório somente dataset curado e relatório sanitizado; manter resultados brutos temporários fora do PR. +10. Rever independentemente gabarito/origem e código do avaliador antes do PR. + +### Critério S2-17 + +- Dataset final versionado, com origem verificável e cobertura representativa das categorias do cartão. “20+ consultas” é referência prática, não requisito formal se o cartão não o disser nem garantia estatística; ampliar negativos para evitar conclusões baseadas em 1–2 exemplos. +- Baseline repetível contra o SHA, corpus e limiares candidatos ao PR. +- Zero vazamentos; latência dentro do orçamento; relatório apresenta por categoria todos os falsos positivos/negativos e limitações. +- Harness detecta os casos inválidos acima e não grava dados sensíveis. + +## 9. Fase 5 — Revisão completa dos quatro fluxos + +1. Aplicar todas as migrations numa base vazia terminada em `_test` e confirmar sequência idempotente. +2. Rodar integrações PostgreSQL de S2-01/02 e S2-06 no banco QA. A execução ampla mais recente teve 347 aprovações e uma falha em `knowledge-indexer.db.test.ts`, pertencente a S2-03/04; isso não demonstra dependência de S2-17. Registrar a falha fora de escopo e não reportar a suíte integrada como totalmente verde. Só voltar a rodar esse teste ao preparar S2-03/04 ou se um vínculo técnico indispensável for comprovado. +3. **Concluído nesta continuação:** executar `python scripts/smoke_document_lifecycle.py --confirm-disposable` contra backend/API, PostgreSQL, serviço IA e Ollama reais, com conta/projeto no banco descartável. PDF, DOCX, MD e TXT passaram por upload, processamento, busca com origem e remoção. +4. Executar suites backend, Python e frontend; typecheck e builds; teste de seed v1/v2; `npm audit --omit=dev` e auditoria Python disponível no projeto. +5. Rebuildar containers backend, IA e frontend conforme arquivos alterados; atualizar stack local e conferir `/health` e logs sem segredos. +6. Inspecionar a UI em desktop e viewport estreito: estados de documentos, progresso, falha/retry, busca, filtros, resultados, links e ausência de resultados. Registrar capturas QA sem dados pessoais. +7. Fazer revisão de acessibilidade básica: labels, foco/teclado, mensagens de erro/status, contraste e botões desabilitados/enabled. +8. Revisar todas as rotas tocadas contra OpenAPI; validar 400/401/403/404/409/413 e comportamento de projeto arquivado, conforme aplicável. +9. Ler cada diff e conferir dependências/compatibilidade; excluir alterações S2-03/04/05/08 não necessárias às quatro tarefas. +10. Confirmar que nenhum artefato `tmp/`, cookie/token, arquivo de banco, segredo ou documento QA não curado está staged. + +**Atualização 03/10/2026:** serviço Python 60/60; integração PostgreSQL de documentos 17/17 e de busca 1/1; backend sem DB 290 aprovados/0 falhas/19 ignorados; frontend 206/206 + build; backend typecheck/build; auditoria backend sem vulnerabilidades; configuração Docker e `git diff --check` aprovados. O banco QA já não existe no PostgreSQL. Continua bloqueado o aceite de relevância S2-06/S2-17: Q008 permanece perdida e a equipe ainda não definiu metas formais; baseline final deve ser produzido depois da decisão de produto. A falha da suíte ampla em S2-03/04 segue separada e sem resolução. + +**Atualização complementar 03/10/2026:** o harness S2-17 foi endurecido para aceitar somente API local, registrar SHA/hash do snapshot de código/dataset/corpus e expor ranking por consulta; suíte Python completa 62/62. Baseline autenticado reexecutado no candidato em base descartável: 26/26 consultas, zero vazamentos, mas p95 2.261 ms e 0/26 abaixo de 2 s. Diagnóstico: Q008 é a paráfrase “PDI” → “plano de desenvolvimento individual” que some sob `0.55`; Q022 confunde menção genérica a gráficos no projeto solicitado com evidência de saldo de crédito que só existe em outro projeto. A execução mediu o gate de latência e falhou; é necessário investigar inferência/ambiente sem alterar o limite silenciosamente. O resultado e a proveniência estão em [avaliação PRE-06 v2](QA_SEARCH_V2_2026-10-02.md). + +## 10. Fase 6 — Empacotar um único PR + +### Preparação + +1. Primeiro concluir o inventário e separar o escopo. Só então integrar a `main` remota atualizada; como o working tree está misturado e sem commits, não fazer rebase/reset/merge às cegas. Preservar uma cópia recuperável antes de qualquer operação que possa conflitar e resolver conflitos mantendo a origem de cada alteração. +2. Separar o conteúdo por commits lógicos dentro do mesmo branch/PR, mantendo um único PR: + - commit/parte A: S2-01 + S2-02 (pipeline, extração, testes e documentação de ingestão); + - commit/parte B: S2-06 (busca, filtros, UI de resultado necessária, migrations e testes); + - commit/parte C: S2-17 (dataset final, runner, métricas, relatório e comandos QA). +3. Mudanças compartilhadas entram no commit cuja responsabilidade principal as exige; a descrição do PR registra dependências e arquivos multi-task. +4. Incluir S2-03/04/05/08 só quando a Fase 0 demonstrar dependência técnica indispensável. Caso contrário, preservá-las localmente para PR próprio, sem descartá-las. +5. Revisar o diff staged commit a commit, e depois o diff combinado final. Conferir estatísticas para detectar arquivo indevido, gerado ou temporário. + +### Descrição e gates + +1. Escrever descrição única do PR com resumo, tabela S2-01/02/06/17, dependência formal de S2-17, decisões de corpus, migrations, variáveis de ambiente, resultados de testes e limitações de qualidade. +2. Publicar relatório QA sanitizado e linkar dataset/documentação; nunca anexar cookie/resultados brutos com conteúdo sensível. +3. Abrir PR contra `main` somente após todos os gates locais estarem verdes e solicitar revisão independente. +4. Esperar CI no SHA exato do PR; corrigir comentários bloqueadores e executar novamente testes afetados. +5. Após aprovação, informar quais tarefas estão tecnicamente prontas e quais podem ser concluídas no Trello de acordo com o DoD do time. O merge permanece separado da autorização para encerrar os cartões. + +## 11. Ordem total e estimativa de esforço + +| Fase | Saída | Esforço aproximado | +|---|---|---:| +| 0. Inventário e escopo | Mapa de paths/dependências; escopo do PR | 0,5 dia | +| 1. S2-01 | Falha/retry/restart/concurrency verificados | 0,5–1 dia | +| 2. S2-02 | Smoke automatizado quatro formatos até remoção | 1–2 dias | +| 3. S2-06 | Corpus ampliado, Q008/Q022 diagnosticados e ranking revalidado | 2–3 dias | +| 4. S2-17 | Harness final e relatório na build candidata | 1–2 dias | +| 5. Regressão integrada/UX | Todas as suites, Docker, rotas e UI | 1–2 dias | +| 6. PR único | Diff limpo, commits lógicos, CI e review | 0,5–1 dia + espera de review | + +As estimativas são planejamento inicial, não promessa de calendário. S2-17 pode ser desenvolvido em paralelo durante as fases 1–3, mas sua **execução de aceite** fica depois da estabilização de S2-01/02/06. + +## 12. Lista de verificação para declarar pronto ao envio + +- [ ] Dependências formais mantidas: avaliação S2-17 executada sobre S2-01, S2-02 e S2-06 integradas. +- [ ] Fase 0 identificou e excluiu mudanças fora das quatro tasks, sem apagar alterações locais. +- [ ] S2-01 passou integração de estados, failure/retry, leases, restart e concorrência. +- [ ] S2-02 passou smoke repetível de processamento PDF, DOCX, MD e TXT; ciclo completo de busca/remoção foi validado em fixture representativa (idealmente os quatro formatos). +- [ ] S2-06 passou filtros, IDs, busca de conteúdo, isolamento, query negativa, rota/link e latência. +- [ ] S2-17 executou dataset/corpus versionados no SHA candidato, sem segredos, com relatório por categoria. +- [ ] Q008/Q022 e qualquer outro FP/FN estão corrigidos ou explicitamente limitados no relatório; nenhuma regressão escondida pelo gabarito. +- [ ] Suites backend/Python/frontend, typecheck, builds, migrations, segurança e health passaram. +- [ ] Inspeção UX/rotas/API documentada; nenhuma tela de fluxo principal ficou sem estado de loading/empty/error. +- [ ] Diff staged não inclui temporários ou tasks alheias; commits têm separação lógica sob o mesmo PR. +- [ ] PR único criado contra main, CI verde no SHA final e revisão por par solicitada. diff --git a/docs/QA_SEARCH_V2_2026-10-02.md b/docs/QA_SEARCH_V2_2026-10-02.md new file mode 100644 index 0000000..9c51e03 --- /dev/null +++ b/docs/QA_SEARCH_V2_2026-10-02.md @@ -0,0 +1,99 @@ +# Avaliação de busca — corpus PRE-06 v2 + +**Data:** 02/10/2026 (America/Sao_Paulo) +**Dataset:** `sinapse-search-ptbr` v2, 26 consultas +**Corpus:** `pre06-historical-v2`, seis chunks curados +**Execução do resultado principal:** API autenticada local, PostgreSQL/pgvector descartável, embeddings locais `bge-m3`; corte vetorial `0.30`, rank textual mínimo `0.05`; Ollama aquecido antes da medição. `0.55` foi avaliado separadamente na matriz abaixo e escolhido depois como padrão local provisório. +**Código-base:** `8c9d04aa537216505afe95b5e4436a11eac55e61` com alterações locais ainda não commitadas. Hardware não foi registrado; latências valem apenas para esta execução. + +## Resultado + +| Métrica | PRE-06 v1 anterior | PRE-06 v2 | Variação | +|---|---:|---:|---:| +| Recall@5 macro | 88,9% | 100% | +11,1 p.p. | +| Precision@5 macro | 17,8% | 20,0% | +2,2 p.p. | +| MRR@5 | 88,9% | 100% | +11,1 p.p. | +| Consultas sem evidência respondidas vazias | 16,7% (1/6) | 16,7% (1/6) | sem mudança | +| Latência p50 / p95 | 130 / 145 ms | 114 / 137 ms | -16 / -8 ms | +| Consultas em até 2 s | 24/24 | 26/26 | ambas dentro do limite | +| Violações de isolamento | 0 | 0 | sem mudança | + +Recall e MRR de 100% são do resultado principal a `0.30`; incluem os identificadores exatos `GRF-01` e `GRF-08` e as duas consultas novas por conteúdo. Isso demonstra os dois modos de localizar os requisitos na fixture v2. A v1 e seus IDs continuam preservados. + +### Matriz exploratória de corte vetorial + +Cada ponto reexecutou as mesmas 26 consultas, com `SEARCH_MIN_TEXT_RANK=0.05`. A acurácia sem evidência considera seis consultas com lista esperada vazia. Valores servem para comparação no corpus pequeno, não como SLA. + +| Corte vetorial | Recall@5 | Acurácia sem evidência | Falsos positivos nos negativos | Consultas positivas com evidência perdida | +|---:|---:|---:|---|---| +| 0.30 | 100% | 16,7% (1/6) | Q017, Q018, Q019, Q020, Q022 | nenhuma | +| 0.45 | 100% | 33,3% (2/6) | Q017, Q018, Q020, Q022 | nenhuma | +| 0.50 | 95% | 66,7% (4/6) | Q020, Q022 | Q008 | +| 0.55 | 95% | 83,3% (5/6) | Q022 | Q008 | +| 0.60 | 85% | 100% (6/6) | nenhum | Q001, Q008, Q021 | + +Dois cortes lexicais adicionais (`0.10` e `0.20`, com corte vetorial `0.30`) produziram as mesmas métricas do baseline, sugerindo que os falsos positivos observados vêm do sinal semântico nesta fixture. Por decisão provisória do usuário/PO em 02/10/2026, `0.55` passa a ser o padrão local. A calibração permanece provisória até avaliação em corpus maior e aceite formal das metas de qualidade. + +No corte `0.55`, os dois modos pedidos seguem íntegros: consultas de identificador exato `GRF-01/08` e as duas consultas de conteúdo ficaram com 100% de recuperação. A perda foi em paráfrase semântica (Q008: “Qual funcionalidade registra um PDI anual ligado ao funcionário?”), então a recuperação dessa categoria cai de 100% para 87,5% (7/8). Isso explicita o custo do corte: reduz respostas indevidas, mas esconde uma evidência pertinente. + +### Métricas por categoria no corte provisório `0.55` + +| Categoria | Consultas | Recall@5 | Acurácia sem evidência | p95 | +|---|---:|---:|---:|---:| +| Identificador exato | 4 | 100% | — | 126 ms | +| Busca por conteúdo | 2 | 100% | — | 111 ms | +| Termo exato | 2 | 100% | — | 123 ms | +| Paráfrase semântica | 8 | 87,5% | — | 140 ms | +| Sem resultado relevante | 3 | — | 100% | 124 ms | +| Isolamento entre projetos | 3 | 100% nas respondíveis | 50% nas sem evidência | 126 ms | + +Comparado ao baseline `0.30`, as consultas de paráfrase caíram 12,5 pontos percentuais em Recall@5; a acurácia sem evidência subiu de 0% para 100% na categoria sem resultado relevante e de 0% para 50% nas consultas negativas de isolamento. O único falso positivo restante é Q022, na categoria de isolamento. Essas seis consultas negativas ainda são poucas para representar um acervo real. + +## Lacuna bloqueadora + +A busca ainda não sabe se abster quando o acervo não contém resposta: no baseline `0.30`, cinco das seis consultas com gabarito vazio retornaram uma ou mais fontes. No corte provisório `0.55`, esse número caiu para uma (Q022). O isolamento permaneceu correto: as fontes retornadas sempre pertenciam ao projeto solicitado, mas algumas eram irrelevantes dentro daquele projeto. + +Portanto, a melhoria de códigos/conteúdo está demonstrada, mas **S2-06 e S2-17 ainda não atingem aceite de qualidade**. O próximo trabalho é calibrar abstenção sem perder o recall alto, expandir exemplos negativos e acordar com PO/time metas de relevância. O padrão local `0.55` é provisório e deve ser reavaliado com nova comparação. + +### Diagnóstico dos casos restantes (revisão de 03/10/2026) + +- **Q008 (falso negativo em `0.55`):** o gabarito aponta para a evidência API-2 `62000000-0000-4000-8000-000000000010`, cujo texto descreve um “plano de desenvolvimento individual” associado a colaborador e ano. A consulta usa a sigla “PDI” e “funcionário”. A relação é semântica, mas não lexicalmente explícita; o corte maior remove o candidato vetorial. Em `0.30` a evidência reaparece, ao custo de mais falsos positivos no corpus. Isto é sensibilidade de ranking/corte, não gabarito sem evidência. +- **Q022 (falso positivo em `0.55`):** a consulta pede saldo de crédito em gráfico de linha, mas o projeto consultado é API-1, cujo chunk retornado é `62000000-0000-4000-8000-000000000005` (“Métricas de equipe em gráficos”). A evidência de saldo/gráfico de crédito está no projeto API-3 (`62000000-0000-4000-8000-000000000013`), que não pode ser retornado por causa do isolamento obrigatório. A consulta foi corretamente escopada; o defeito é a abstenção/ranking semântico que considera o chunk genérico de gráficos como resposta. +- **Decisão técnica:** não alterar o gabarito, adicionar sinônimos só para estes exemplos nem elevar o corte para ocultar o caso. A correção robusta demanda recuperação/reranking calibrados com negativos e paráfrases adicionais, mantendo isolamento e reavaliando recall por categoria. + +## Reprodutibilidade e privacidade + +- Bancos `sinapse_s2qa_execution_20261002_test` e `sinapse_s2qa_matrix_20261002_test` criados exclusivamente para a avaliação/matriz e removidos ao final; backends temporários na porta 3002 foram encerrados. +- A execução usou 26 chamadas autenticadas ao endpoint real. O runner gravou resultados detalhados apenas em `tmp/`; esse JSON local não deve ser commitado. +- Nenhum cookie, senha, usuário QA ou URL de banco foi incluído neste relatório. +- Regressão isolada de S2-06 confirmou busca literal por localizador, filtro de projeto e ausência de retorno para identificador que não existe dentro do escopo. Todos os cinco cortes da matriz tiveram 0 violações de isolamento e 26/26 consultas abaixo de 2 s. +- Identificadores existentes e inexistentes também foram testados em minúsculas na integração PostgreSQL; 1/1 suíte passou. + +## Verificações da v2 + +- `SEED_DATASET_VERSION=2 npm run seed:validate`: manifesto e seis documentos validados. +- `npm run test:seed`: cobertura da seleção explícita da v2 e metadados `GRF-01/08`. +- `python -m unittest discover -s ai-service/tests`: 57 testes passaram, incluindo agregação por categoria e validação de IDs, consultas, versões, fontes desconhecidas e isolamento. +- Backend: 290 testes passaram, 19 foram ignorados por dependerem de ambiente; typecheck, build e validação/teste de seed passaram. Frontend: 206/206 testes e build passaram. `npm audit --omit=dev`: zero vulnerabilidades. +- Harness S2-17: 57 testes Python passaram após acrescentar métricas agregadas por categoria e validações adversariais; relatórios locais recalculados para o baseline `0.30` e o corte `0.55`. +- Avaliador independente aplicado ao resultado autenticado de 26 consultas; 0 violações de isolamento. + +## Adendo — fechamento do harness em 03/10/2026 + +O runner agora recusa URLs remotas e envia cookie somente para HTTP loopback. Cada nova execução registra SHA do commit, flag de working tree sujo e hashes SHA-256 do snapshot versionado/não ignorado, dataset e fixture de corpus; `.env*` e `tmp/` são excluídos. O avaliador inclui posição, fonte e score por consulta, além das métricas agregadas, facilitando diagnóstico de FN/FP. Os testes Python completos passaram 62/62. + +### Baseline ponta a ponta do candidato local — 03/10/2026 + +Executado após aquecer explicitamente o serviço local de embeddings, usando backend temporário na porta 3002, PostgreSQL `_test` isolado com migrations 001–017, seed público PRE-06 v2, seis embeddings gerados localmente, conta descartável e as 26 consultas autenticadas. Backend e banco temporários foram removidos depois da execução. Não foram medidos hardware, CPU/RAM do servidor de inferência nem concorrência; o resultado caracteriza somente este ambiente e esta execução. + +| Métrica | Resultado do candidato | Gate observado | +|---|---:|---| +| Recall@5 / Precision@5 / MRR@5 | 95% / 19% / 95% | sem meta de relevância acordada | +| Acurácia sem evidência | 83,3% (5/6) | Q022 ainda retorna fonte irrelevante | +| Latência p50 / p95 | 2.129 / 2.261 ms | **0/26 dentro de 2.000 ms** | +| Violações de isolamento | 0/26 | aprovado | +| Q008 / Q022 | `[]` / `62000000-0000-4000-8000-000000000005` | falso negativo / falso positivo | + +Proveniência do run: base Git `4dc3033e88fa68ad0f8d73874353cba8a5bd6083`, working tree modificado; SHA-256 do snapshot `f58bf65c980ed5a4d9582ff57aff4cabd332f792c376dd686ede9a020bfde0d8`; dataset `fbbf6c44e48663e99ef81cf9d9e57c73c74805f1d87df64dc5105eac92525870`; corpus `1da7f65bb73f78670ede7037e50f4396b481a7f25af173c76b1053b4ed9962`; embedding `bge-m3`; corte vetorial/textual `0.55/0.05`; Ollama aquecido. O fingerprint identifica o snapshot executado antes da inclusão deste registro. Depois do run, somente documentação e nome de um teste foram editados; código de runtime, configuração, dataset e corpus permaneceram iguais. Resultados brutos ficam em `tmp/`, fora do commit. + +**Conclusão:** a execução final do harness foi concluída e identificou que o candidato atual não cumpre o orçamento de latência observado (todas as consultas >2 s), além do tradeoff Q008/Q022 já descrito. Assim, S2-17 tem ferramenta e baseline executado, mas a entrega conjunta com S2-06 ainda não está aprovada para PR/aceite. Próximo passo técnico: investigar custo de inferência/ambiente e opções de otimização ou hardware, sem relaxar os 2 s nem o isolamento silenciosamente; depois repetir a bateria no candidato otimizado e acordar metas de relevância com PO/time. diff --git a/docs/README.md b/docs/README.md index ee6d409..a1a7b63 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,8 @@ Este índice separa os guias operacionais, contratos e especificações de produ |---|---| | [Guia de setup](SETUP_GUIDE.md) | Docker Compose, execução local, variáveis, testes e solução de problemas. | | [Testes E2E](../e2e/README.md) | Suítes de navegador, dependências e execução com dados descartáveis. | +| [Avaliação da busca (S2-17)](../ai-service/evaluation/README.md) | Dataset PT-BR versionado, gabarito PRE-06, métricas e execução do avaliador. | +| [Estado QA de 02/10/2026](STATUS_REVISAO_2026-10-02.md) | Evidências por entrega local, gates executados, pendências de aceite e plano de correção da busca. | | [Migrations](../database/migrations/README.md) | Baseline, migrations versionadas e validação do banco. | | [Seed](../database/seed/README.md) | Acervo curado, validação, modo de aplicação e política de segurança. | @@ -29,7 +31,9 @@ Este índice separa os guias operacionais, contratos e especificações de produ - [Integração de remoção de documentos](integrations/n8n-document-removed.example.json): exemplo do evento enviado ao n8n. - [Documento de integração](DOCUMENTOS_INTEGRACAO.md): fluxos entre serviços e convenções de integração. -## QA e decisões registradas +## QA e registros históricos + +Os planos de correção por PR abaixo guardam evidências e decisões da época. Não são o status atual das PRs; use o [registro QA de 02/10/2026](STATUS_REVISAO_2026-10-02.md) para o checkout atual. - [Matriz de cenários S1-23](qa/S1-23-matriz-cenarios.md) - [Roteiro de review S1-23](qa/S1-23-roteiro-review.md) diff --git a/docs/RELATORIO_FINAL_QA_S2_2026-10-02.md b/docs/RELATORIO_FINAL_QA_S2_2026-10-02.md new file mode 100644 index 0000000..dc6a0f6 --- /dev/null +++ b/docs/RELATORIO_FINAL_QA_S2_2026-10-02.md @@ -0,0 +1,95 @@ +# Relatório de fechamento da rodada QA — Sprint 2 + +> **Errata da revisão posterior:** `origin/main` agora está em `4dc3033`. A suíte backend com PostgreSQL habilitado executada depois deste relatório teve 347 aprovações e 1 falha em `knowledge-indexer.db.test.ts` (S2-03/04). As afirmações abaixo de que a integração do indexador passou 1/1 e de que a regressão ampla estava verde são históricas e foram contraditas pela execução mais recente; não usar este relatório como prova de aprovação de S2-03/04. O ciclo completo upload→busca→remoção foi comprovado com Markdown; PDF/DOCX/TXT têm evidência anterior de processamento e geração de chunks, não do ciclo completo. + +> **Adendo de continuidade — 03/10/2026:** após o relatório inicial, foram acrescentados testes de fixture PDF/DOCX/MD/TXT e um smoke autenticado real de ciclo completo. A execução real passou para os quatro formatos (upload → processamento → busca com origem → remoção → fonte ausente). A integração de documentos passou de 16/16 para 17/17, incluindo falha seguida de retry sem chunks parciais ou duplicados. Resultado atual: Python 60/60; backend sem DB 290 aprovados, 0 falhas, 19 ignorados; integração PostgreSQL documentos 17/17 e busca 1/1; frontend 206/206 e build; backend typecheck/build; `npm audit --omit=dev` sem vulnerabilidades; Docker Compose config e `git diff --check` aprovados. O banco QA não consta mais no PostgreSQL. Permanecem pendentes critérios de relevância aceitos pelo PO/time e novo baseline final S2-17; Q008 ainda se perde com o corte provisório `0.55`. A falha histórica de S2-03/04 não foi corrigida por esta rodada. O smoke foi executado em backend Docker temporário ligado ao banco de QA, sem expor cookie ou dados pessoais. + +**Data:** 02/10/2026 (America/Sao_Paulo) +**Escopo:** S2-01, S2-02, S2-03/04, S2-06 e S2-17 +**Base:** `8c9d04a` (`origin/main` estava nesse SHA no início da revisão), com implementação e ajustes ainda locais e sem commit. + +## Resumo executivo + +Foram revalidados backend, serviço IA, frontend, migrations e operações reais de ingestão, busca e remoção em um banco PostgreSQL criado apenas para QA. O padrão local de similaridade vetorial foi fixado provisoriamente em `0.55` e propagado para a configuração do Docker Compose. O backend foi reconstruído e recriado; `/health` confirmou o serviço saudável e a configuração em execução retornou `0.55` / `0.05`. + +As correções e verificações não equivalem a aceite formal de Sprint. A qualidade de recuperação continua limitada pelo corpus pequeno; a revisão visual autenticada completa, revisão por pares, CI/PR e aceite Scrum continuam fora desta rodada. + +## Adendo da continuação — fechamento das dependências S2-17 (03/10/2026) + +- Implementado `scripts/smoke_document_lifecycle.py`: smoke autenticado com fixtures sintéticas PDF/DOCX/MD/TXT, prazo limitado do worker, validação de projeto e link de origem, busca, remoção e limpeza em erro. O harness recusa URLs não locais para impedir envio do cookie de sessão a outro host e exige `--confirm-disposable`. +- O ciclo real contra backend temporário em Docker, serviço IA, Ollama e banco descartável passou nos quatro formatos: upload → `processado` → busca encontrou a fonte/projeto/rota corretos → DELETE 204 → fonte ausente. O banco e o container temporários foram removidos ao final. +- Integrações PostgreSQL finais: documentos 17/17, incluindo falha simulada da IA → retry manual → conclusão sem chunk parcial/duplicado; S2-06 busca 1/1. Suite Python 60/60; backend 290 aprovados, 0 falhas e 19 ignorados por dependência de ambiente; backend typecheck/build; frontend 206/206 e build; auditoria backend 0 vulnerabilidades. +- O smoke contra backend host foi bloqueado pela política automática; o mesmo fluxo foi validado em container Docker temporário e confinado ao banco `_test`. +- As métricas de relevância e a baseline final S2-17 ainda precisam ser reavaliadas. Q008/Q022 seguem como regressões do corpus pequeno; `0.55` continua provisório, sem metas de produto aprovadas. + +## Alterações realizadas + +- Backend: `SEARCH_MIN_VECTOR_SIMILARITY` agora tem padrão `0.55`; teste de configuração verifica esse padrão. +- Docker Compose: encaminha `SEARCH_MIN_VECTOR_SIMILARITY` e `SEARCH_MIN_TEXT_RANK` ao backend, com valores padrão `0.55` e `0.05`. +- Harness S2-17: adiciona métricas por categoria (Recall@k, acurácia sem evidência e latência p50/p95) e testes adversariais para identificadores duplicados, consultas ausentes, fontes duplicadas/desconhecidas, projetos incorretos e versões incompatíveis. +- Documentação: matriz e métricas por categoria atualizadas; números históricos são identificados como baseline `0.30`, separando-os da escolha local posterior `0.55`. + +## Evidência de integração PostgreSQL + +Foi criado o banco descartável `sinapse_s2qa_full_test`, aplicado o conjunto completo de migrations `001`–`017` em banco vazio e executados: + +| Integração | Resultado | +|---|---:| +| Repositório de documentos, auditoria, outbox, leases, concorrência e remoção | 16/16 | +| Busca híbrida, escopo de projeto, localizador, tecnologia e nível | 1/1 | +| Indexação, idempotência e expurgo ao arquivar/reabrir | Resultado histórico 1/1; execução integrada mais recente falhou em `knowledge-indexer.db.test.ts` | + +O banco de integração foi removido. Um segundo banco descartável foi usado no teste de ciclo completo abaixo e também removido após a execução. + +## Teste real de ciclo documental + +Com backend isolado na porta `3002`, banco QA próprio e o serviço IA local: + +1. Cadastro temporário de PO e criação de projeto de teste: HTTP `201`. +2. Upload de Markdown: HTTP `201`. +3. Worker concluiu a ingestão e gravou chunks com embeddings locais: estado `processado`. +4. Consulta autenticada por frase exclusiva encontrou a fonte do documento: HTTP `200`. +5. Remoção do documento: HTTP `204`. +6. Nova consulta não retornou mais a fonte removida. + +O banco e backend temporários foram encerrados/removidos. O backend Compose foi reconstruído e está saudável na porta `3001`. A evidência anterior registrada em `STATUS_REVISAO_2026-10-02.md` cobre ingestão de PDF, DOCX, MD e TXT; este novo ciclo até busca e remoção foi executado com Markdown. + +## Regressões e qualidade + +| Área | Resultado | +|---|---| +| Backend | 290 aprovados, 0 falhas, 19 ignorados por dependerem de ambiente | +| Backend typecheck / build | Aprovados | +| Integração real de busca S2-06 | 1/1 aprovado em banco descartável | +| Serviço IA | 57 testes aprovados | +| Frontend | 206 testes aprovados; build de produção aprovado | +| Dependências backend | `npm audit --omit=dev`: 0 vulnerabilidades | +| Docker Compose | Configuração renderizada com os limiares esperados; imagem backend construída e container atualizado | +| Saúde runtime | `/health`: saudável, banco conectado; backend carregou `0.55` vetorial e `0.05` textual | +| Diff whitespace | `git diff --check` aprovado; Git emitiu somente avisos de conversão LF/CRLF no Windows | + +## Avaliação de relevância no corpus PRE-06 v2 + +O resultado principal a `0.30` foi executado antes da escolha do novo padrão. A matriz armazenada contém 26 consultas e seis chunks curados. Recalcular o relatório por categoria não reexecutou as consultas; agregou os resultados autenticados já coletados. + +No corte provisório `0.55`: Recall@5 `95%`, acurácia sem evidência `83,3%` (5/6), p95 `136 ms`, zero violações de isolamento. Identificadores exatos e busca por conteúdo ficaram com 100% de Recall; paráfrases semânticas, 87,5% (7/8). A busca continua retornando um falso positivo (Q022) e perde a evidência pertinente de Q008. O corpus pequeno não permite concluir que o mesmo equilíbrio se manterá em repositórios reais. + +O corte foi escolhido pelo usuário como provisório. Não existe ainda critério de produto validado para dizer que esse equilíbrio conclui S2-06/S2-17. + +## Inspeção visual + +A tela pública de login foi aberta em viewport desktop: campos de e-mail/senha e botão “Entrar” estão identificados no accessibility tree, e a tela renderizou sem falha visual evidente. Os testes de componentes cobrem documentos, busca e administração. + +A inspeção **autenticada** das áreas de documentos, filtros, resultados, origem e ausência de resultados não foi executada no navegador: a skill `computer-use` disponível neste ambiente proíbe automatizar diálogos de autenticação. Isso não bloqueou testes por API com a conta temporária QA nem as suítes de UI automatizadas, mas deixa a revisão visual autenticada pendente. + +## Pendências técnicas e de processo + +1. Ampliar corpus e consultas negativas/paráfrases com curadoria rastreável; Q022 e Q008 permanecem casos de regressão. +2. Tornar repetível em CI o smoke completo dos quatro formatos (upload → processamento → busca → origem → remoção). Nesta rodada o ciclo completo foi confirmado com Markdown; o histórico anterior cobre os quatro formatos no processamento. +3. Revisão visual autenticada das telas de conhecimento/documentos e filtros/results, quando houver fluxo de revisão visual autorizado sem automação proibida de login. +4. Revisão por par, CI no SHA final, PR e aceite do Trello continuam necessários antes de declarar tarefas concluídas. +5. O working tree contém alterações preexistentes de várias tarefas e artefatos locais sob `tmp/`; não devem entrar em commit sem revisão e filtragem. Uma tentativa anterior de remover alguns artefatos foi bloqueada por revisão automática; não houve nova tentativa de contornar esse bloqueio. + +## Conclusão QA + +As regressões técnicas e o ciclo principal de ingestão/busca/remoção passaram no ambiente local isolado. O backend atualizado está saudável com os limiares escolhidos. S2-01/02 têm evidências fortes de implementação e ingestão; S2-06/S2-17 continuam em correção de relevância devido ao falso positivo e à evidência perdida. Nenhuma tarefa foi marcada formalmente como concluída, e não houve commit, push ou PR nesta rodada. diff --git a/docs/SETUP_GUIDE.md b/docs/SETUP_GUIDE.md index 2821bc6..851bf35 100644 --- a/docs/SETUP_GUIDE.md +++ b/docs/SETUP_GUIDE.md @@ -22,11 +22,13 @@ Este guia cobre dois caminhos: subir o produto em containers ou executar backend git clone https://github.com/Galaticos-API/API-4.git cd API-4 cp .env.example .env +# Gere um segredo com: python -c "import secrets; print(secrets.token_hex(32))" +# Copie a saída para DOCUMENT_INGESTION_TOKEN no arquivo .env docker compose up --build -d docker compose ps ``` -No PowerShell, substitua `cp .env.example .env` por `Copy-Item .env.example .env`. +No PowerShell, substitua `cp .env.example .env` por `Copy-Item .env.example .env`. Antes de iniciar os containers, gere um valor aleatório com o comando Python mostrado acima e preencha `DOCUMENT_INGESTION_TOKEN` no `.env`; backend e serviço de IA precisam compartilhar esse mesmo segredo. O Compose falha explicitamente se o valor estiver vazio. O Compose padrão inicia PostgreSQL, n8n, backend e frontend. O backend aplica migrations pendentes antes de aceitar tráfego. URLs padrão: @@ -134,7 +136,7 @@ O serviço lê sua configuração a partir de `ai-service/config.py`. O serviço | `BACKEND_PORT` | `3001` | Porta publicada da API. | | `FRONTEND_PORT` | `5173` | Porta publicada da SPA. | | `N8N_PORT` | `5678` | Porta publicada do n8n. | -| `DOCUMENT_MAX_SIZE_MB` | `20` | Tamanho máximo de upload aceito pela API. | +| `DOCUMENT_MAX_SIZE_MB` | `20` | Tamanho máximo de upload aceito pela API e ingestão local (máximo `20`). | | `DOCUMENT_EVENTS_WEBHOOK_URL` | vazio | Destino HTTP dos eventos de remoção de documento. Sem consumidor configurado, o evento permanece pendente e é tentado novamente. | | `OLLAMA_PORT` | `11434` | Porta do Ollama executado no host. | | `OLLAMA_DOCKER_BASE_URL` | `http://host.docker.internal:11434` | URL do Ollama vista pelo `ai-service` dentro do Docker. | diff --git a/docs/STATUS_REVISAO_2026-10-02.md b/docs/STATUS_REVISAO_2026-10-02.md new file mode 100644 index 0000000..9086600 --- /dev/null +++ b/docs/STATUS_REVISAO_2026-10-02.md @@ -0,0 +1,81 @@ +# Estado da implementação e revisão QA — 02/10/2026 + +> **Atualização de escopo e evidência:** a branch foi sincronizada com `origin/main` em `4dc3033`. Este documento contém snapshots anteriores; para o estado operacional atual e decisões de inclusão no PR, seguir [plano canônico de fechamento](PLANO_FECHAMENTO_PR_UNICO_S2.md). A rodada integrada mais recente teve 347 testes aprovados e 1 falha fora do escopo S2-01/02/06/17, em `knowledge-indexer.db.test.ts` (S2-03/04). Portanto, não declarar a suíte integrada completa como verde nem S2-03/04 como aprovadas com base nas linhas históricas abaixo. + +> **Adendo posterior da S2-01/02/06:** integrações PostgreSQL executadas isoladamente nesta continuação passaram: documentos 16/16 e busca 1/1. O smoke autenticado de ciclo completo foi implementado e passou no teste de API simulada para PDF/DOCX/MD/TXT; ainda não foi executado contra o backend/IA/Ollama reais. Python 59/59, backend sem DB 290 aprovados/19 ignorados, frontend 206/206 + build, backend typecheck/build e auditoria 0 vulnerabilidades passaram. A qualidade de relevância continua pendente. + +> **Adendo QA de 03/10/2026 (evidência mais recente):** o smoke autenticado real passou com PDF, DOCX, MD e TXT: upload, processamento, busca com origem e remoção. Integrações PostgreSQL isoladas: documentos 17/17 (inclui falha→retry manual→conclusão, sem chunks parciais/duplicados) e busca 1/1. Serviço Python 60/60; backend sem DB 290 aprovados, 0 falhas e 19 ignorados; frontend 206/206 e build; backend typecheck/build; `npm audit --omit=dev` sem vulnerabilidades; Docker Compose config e `git diff --check` aprovados. O banco temporário já não consta no PostgreSQL. Esses resultados não resolvem o aceite de relevância S2-06/S2-17: Q008 continua perdida com `0.55`, a métrica de abstenção no corpus pequeno ainda não atende consenso formal e o baseline final ainda precisa ser executado após a decisão PO/time. A falha histórica de S2-03/04 permanece fora de escopo e sem resolução. + +> **Complemento QA S2-17, 03/10/2026:** runner fechado tecnicamente com bloqueio de URLs remotas para proteger o cookie, SHA do commit + indicador de working tree + fingerprint do código/dataset/corpus, e resultados avaliados com ranking por consulta. Python 62/62. O cookie QA antigo responde 401 no backend atual, então a bateria autenticada ainda não foi repetida com o runner novo; análise e próximos gates estão em [QA_SEARCH_V2_2026-10-02.md](QA_SEARCH_V2_2026-10-02.md). S2-17 continua implementada, mas a execução final do baseline segue pendente. + +> **Errata, mesmo dia:** o baseline foi repetido com novo banco/projeto/usuário descartáveis e runner novo. A bateria de 26 consultas autenticadas terminou com Recall@5 95%, acurácia sem evidência 83,3%, p50/p95 2.129/2.261 ms, 0/26 dentro de 2 s e zero violações de projeto. Portanto a execução já não está pendente; seu gate de latência falhou e Q008/Q022 seguem abertas. Proveniência e diagnóstico detalhados no relatório PRE-06 v2. + +Este registro separa **implementação no checkout local**, **validação técnica** e **conclusão formal no Scrum**. As alterações revisadas ainda não foram commitadas nem enviadas à `main`; portanto, não contam como tarefas formalmente concluídas no quadro. + +Complemento da revisão e evidências executadas em 02/10: [relatório final QA](RELATORIO_FINAL_QA_S2_2026-10-02.md). + +## Resultado executivo + +- A Sprint 1 permanece concluída em 27/09/2026. +- O início planejado da Sprint 2 é 05/10/2026. O trabalho abaixo é preparação implementada localmente antes da data planejada, não encerramento da Sprint 2. +- HEAD local e `origin/main` estavam no mesmo commit (`8c9d04a`) no início desta revisão. As alterações listadas estão no working tree. +- Os fluxos de ingestão, indexação, expurgo e UI de busca têm implementação local e testes relevantes. O usuário aprovou manter os dois modos de busca: por identificador e por conteúdo. Foi criada PRE-06 v2 com IDs isolados e metadados rastreáveis; a fixture v1 permanece intacta. A nova avaliação melhorou recuperação, mas abstenção segue reprovada. +- O Trello e o PR não foram atualizados por esta revisão. Checklist no código não substitui revisão por pares, CI no commit final ou aceite PO. + +## Acompanhamento por entrega + +| IDs | Entrega | Implementação local | Evidência técnica | Pendência para aceite | +|---|---|:---:|---|---| +| S2-01 / S2-02 | Ingestão em segundo plano, extração PDF/DOCX/MD/TXT, chunking, embeddings, estados e retry | Implementada, aceite pendente | Integração PostgreSQL de documentos passou 17/17; smoke real upload→processamento→busca/origem→remoção passou para PDF, DOCX, MD e TXT | Revisão por pares, CI no SHA final e aceite formal | +| S2-03 / S2-04 | Indexação de itens concluídos/decisões, idempotência e retirada de arquivados/reabertos | Inconclusiva | Execução integrada mais recente falhou em `knowledge-indexer.db.test.ts`; há evidência histórica conflitante, que não deve ser tratada como aprovação | Diagnosticar e validar em trabalho próprio; fora do PR S2-01/02/06/17 salvo dependência provada | +| S2-05 | Expurgo administrativo do contexto do projeto | ✅ | Rota/repositório/UI e testes; suites e build passaram | Revisar o fluxo autenticado com confirmação em navegador; aceite formal | +| S2-06 / S2-07 | Busca híbrida, filtros e isolamento por projeto | ⚠️ melhoria local em revisão; ❌ aceite de qualidade | `0.55` escolhido provisoriamente: Recall@5 95%, ausência 83,3%, 0 vazamentos; perde Q008 | Ampliar corpus negativo, acordar metas finais e reavaliar | +| S2-08 | Interface dos resultados e navegação para fonte | ✅ | Contrato alinhado à API; 3 testes de UI e suite frontend passaram | Revisão visual autenticada e aceite formal | +| S2-17 | Bateria de consultas, avaliador e métricas | ⚠️ harness endurecido; avaliação v2 executada; ainda não aprovada | 26 consultas via API/PostgreSQL/pgvector/Ollama; baseline experimental 0.30: Recall@5 100%, p95 137 ms, 0 violações; 16,7% ausência; matriz até 0.60; padrão local agora 0.55 provisório | Aumentar negativos e acordar metas formais com PO/time | +| S2-09 a S2-16, S2-18 a S2-21 | Governança/copiloto, dependências, versões, DoR/DoD e integração completa | ⬜ | Não fazem parte das alterações auditadas neste checkout | Continuam no planejamento e precisam de implementação/revisão próprias | + +## Resultado da avaliação ponta a ponta da busca + +Corpus PRE-06 de seis chunks e 24 consultas, via API local autenticada, Ollama e PostgreSQL descartável. Métricas de uma execução, não SLA universal: + +| Configuração | Recall@5 | Acurácia sem evidência | p50 / p95 | Isolamento | +|---|---:|---:|---:|---:| +| Corte vetorial inicial `0.3` (execução 02/10) | 88,9% | 16,7% (1/6) | 130 / 145 ms | 0 violações | +| Experimento `0.6` (execução anterior) | 72,2% | 100% (6/6) | 138 / 207 ms | 0 violações | +| PRE-06 v2, regra de identificador exato + conteúdo | 100% | 16,7% (1/6) | 114 / 137 ms | 0 violações | +| PRE-06 v2, corte vetorial `0.55` exploratório | 95% | 83,3% (5/6) | p95 136 ms | 0 violações | +| PRE-06 v2, corte vetorial `0.60` exploratório | 85% | 100% (6/6) | p95 131 ms | 0 violações | + +Todas ficaram abaixo do limite de latência de 2.000 ms nesse ambiente pequeno. O usuário aprovou manter localizadores exatos e consultas por conteúdo; a v2 preserva os 24 casos v1 e acrescenta dois casos por conteúdo. O corte `0.55` foi escolhido provisoriamente como padrão local: na v2, deixa um falso positivo entre seis consultas sem evidência e perde uma consulta positiva (Q008). A qualidade ainda não tem aceite formal do PO/time e requer corpus ampliado. Detalhes em [avaliação S2-17 v2](QA_SEARCH_V2_2026-10-02.md). + +## Verificações executadas + +- Backend: 290 testes aprovados, zero falhas; 19 ignorados no comando geral por dependerem de ambiente. As integrações relevantes foram executadas separadamente abaixo. +- Typecheck backend: aprovado. +- Integração PostgreSQL S2-01/documentos: 16/16; S2-06/busca: 1/1. A execução ampla mais recente falhou no indexador S2-03/S2-04; não registrar sua integração como aprovada até reproduzir e resolver a falha. +- Frontend: 206 testes aprovados; build de produção aprovado. +- Serviço Python: 60 testes aprovados após cobertura do smoke de ciclo de vida e validação local-only do destino autenticado. +- Auditoria de dependências backend: zero vulnerabilidades reportadas. +- Imagens Docker de backend/frontend recompiladas; backend `/health` saudável e rota SPA `/admin` respondeu HTTP 200. +- `git diff --check`: sem erros de whitespace (avisos de conversão LF/CRLF no Windows). +- E2E S2-02: arquivos PDF, DOCX, Markdown e TXT enviados pela API terminaram como `processado`, foram localizados na busca com origem do documento e removidos com sucesso; a consulta seguinte confirmou que a fonte removida não permanecia nos resultados. Texto de célula DOCX também foi verificado no PostgreSQL depois da reconstrução do container de IA com a correção. +- S2-17: 24/24 consultas pela API autenticada local, PostgreSQL/pgvector e Ollama; p50/p95 130/145 ms, Recall@5 88,9%, MRR@5 88,9%, precisão macro@5 17,8%, acurácia sem evidência 16,7%, 24/24 dentro de 2 s e zero violações de isolamento. +- Ambiente de validação isolado (`sinapse_s2qa_20261002_test`); fixture e usuário de QA foram criados apenas nele. Resultados detalhados não incluem cookie nem dados pessoais. +- O banco descartável da revisão foi removido. Artefatos locais do smoke E2E permanecem porque a política automática bloqueou a remoção de arquivos do storage; eles não foram incluídos em commits. +- O avaliador S2-17 falha diante de fonte fora do projeto; a busca S2-06 dá precedência à correspondência literal para identificadores e não usa fallback semântico nesses casos, sem diferenciar maiúsculas/minúsculas. A PRE-06 v2 aprovada pelo usuário foi criada e avaliada: 26 consultas, Recall@5 100%, Precision@5 20%, MRR@5 100%, acurácia sem evidência 16,7%, p50/p95 114/137 ms, 0/26 violações. A matriz exploratória encontrou melhor ponto observado em 0.55 (Recall 95%, ausência 83,3%, Q008 perdido). Banco de QA removido. O relatório mantém a ressalva de que o working tree não estava commitado no SHA listado. +- A regressão frontend encontrou e corrigiu reset de visualização que podia sobrescrever o clique ao alternar Leitura/Markdown quando a análise selecionada muda; suíte final 206/206 e build de produção aprovados. +- Auditoria de dependências backend: zero vulnerabilidades. +- Inspeção visual autenticada das telas de busca/admin: pendente. Só foi possível conferir a tela pública de login; a conta temporária criada para QA foi removida ao final. +- Regressão adicional em banco PostgreSQL vazio: migrations 001–017 aplicadas; documentos 17/17 e busca 1/1. A suíte ampla subsequente reportou falha no indexador S2-03/04; banco descartável não consta mais no servidor PostgreSQL. +- Ciclo HTTP real em backend separado/banco descartável: upload Markdown 201 → processamento → busca 200 encontrou chunk → remoção 204 → busca seguinte sem chunk. Conta/projeto/banco temporários foram removidos ao descartar o banco. +- Container local backend reconstruído e recriado após incluir os limiares no Compose; `/health` saudável, DB conectado, runtime configurado em `0.55` vetorial e `0.05` textual. +- Métricas do harness agora são agregadas por categoria; avaliador passou a cobrir versão divergente, identificadores duplicados, consulta ausente, fonte duplicada/desconhecida e escopo de projeto. +- A inspeção visual autenticada continua pendente. A skill `computer-use` do ambiente proíbe automatizar diálogos de autenticação; foi conferida apenas a tela pública de login. Suítes de UI automatizadas passaram. + +## Próximas ações + +1. Preservar PRE-06 v1 e v2 e ampliar casos negativos e paráfrases com origem curada. +2. Informar ao PO/time que `0.55` está adotado provisoriamente; o corte perde Q008 e deixa 1/6 negativo com falso positivo, enquanto `0.60` zera esses falsos positivos, mas perde Q001/Q008/Q021. +3. Acordar metas de Recall/Precision/MRR e abstenção; então selecionar configuração e repetir avaliação em corpus maior. +4. Fazer revisão visual autenticada de S2-05 e S2-08 e executar o fluxo completo de documento do upload até consulta. +5. Executar regressão final, revisar diff contra a `main`, abrir/atualizar PR e só então marcar as tarefas concluídas no Trello após aceite. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e8d4862..ff7b36b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1762,19 +1762,34 @@ paths: /api/v1/search: get: - summary: Busca textual no acervo indexado (chunks), com escopo opcional por projeto + summary: Busca híbrida por texto e significado no acervo, sempre isolada por projeto operationId: searchKnowledge security: [{cookieAuth: []}] parameters: - - {name: q, in: query, required: false, schema: {type: string}} - - {name: projeto_id, in: query, required: false, schema: {type: string, format: uuid}} + - {name: q, in: query, required: true, schema: {type: string, minLength: 3, maxLength: 200}} + - {name: projeto_id, in: query, required: true, schema: {type: string, format: uuid}} + - {name: tecnologia_id, in: query, required: false, schema: {type: string, format: uuid}} + - name: nivel + in: query + required: false + schema: + type: string + enum: [documento, decisao, epico, feature, pbi] + - {name: limit, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 50, default: 10}} responses: '200': - description: Trechos encontrados + description: Resultados ordenados por fusão da classificação vetorial e textual + content: + application/json: + schema: + $ref: '#/components/schemas/HybridSearchResponse' '400': $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/Unauthorized' + '404': {description: Projeto não encontrado} + '502': {description: Serviço de embeddings indisponível ou resposta incompatível} + '503': {description: Serviço local de embeddings indisponível} /api/v1/developers: get: @@ -1918,7 +1933,7 @@ paths: description: > O tipo real é verificado pelo conteúdo e pela extensão; o Content-Type informado pelo cliente não é considerado. O limite de tamanho é - configurável (DOCUMENT_MAX_SIZE_MB) e é informado em `limites.max_bytes` + configurável até 20 MiB (DOCUMENT_MAX_SIZE_MB) e é informado em `limites.max_bytes` na listagem. Arquivos recusados não deixam arquivo nem registro. operationId: uploadProjectDocument security: @@ -2002,6 +2017,30 @@ paths: '503': description: Armazenamento indisponível; o documento permanece disponível + /api/v1/projects/{projectId}/documents/{documentId}/retry: + post: + summary: Agenda novamente a ingestão de um documento que falhou (S2-01/S2-02) + operationId: retryProjectDocumentIngestion + security: [{cookieAuth: []}] + parameters: + - {name: projectId, in: path, required: true, schema: {type: string, format: uuid}} + - {name: documentId, in: path, required: true, schema: {type: string, format: uuid}} + responses: + '202': + description: Reprocessamento agendado + content: + application/json: + schema: + type: object + required: [status_processamento] + properties: + status_processamento: {type: string, enum: [pendente]} + '400': {$ref: '#/components/responses/ValidationError'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {description: Perfil sem permissão de escrita} + '404': {description: Projeto ou documento não encontrado neste projeto} + '409': {description: Projeto arquivado é somente leitura} + /webhook/sinapse-document-removed: servers: - url: http://localhost:5678 @@ -3217,8 +3256,10 @@ components: tamanho_bytes: { type: integer, nullable: true, minimum: 1 } status_processamento: type: string - description: pendente = armazenado e aguardando ingestão (S2-01) + description: Estado durável do pipeline de extração e indexação assíncrona (S2-01/S2-02) enum: [pendente, processando, processado, falha] + processamento_erro: {type: string, nullable: true, maxLength: 300} + processamento_tentativas: {type: integer, minimum: 0} armazenamento_pendente: type: boolean description: true enquanto a finalização do arquivo local ainda é reconciliada pelo worker @@ -3363,6 +3404,41 @@ components: items: type: string + HybridSearchResponse: + type: object + required: [items, total, query, project_id, filters, metrics] + properties: + items: + type: array + items: + type: object + required: [id, project_id, project_name, entity_type, entity_id, text, metadata, relevance_score] + properties: + id: {type: string, format: uuid, description: ID estável do chunk para avaliação de busca} + project_id: {type: string, format: uuid} + project_name: {type: string} + entity_type: {type: string, enum: [documento, decisao, epico, feature, pbi]} + entity_id: {type: string, format: uuid} + title: {type: string, nullable: true} + text: {type: string} + metadata: {type: object, additionalProperties: true} + source_url: {type: string, nullable: true} + relevance_score: {type: number} + total: {type: integer} + query: {type: string} + project_id: {type: string, format: uuid} + filters: + type: object + required: [technology_id, level] + properties: + technology_id: {type: string, format: uuid, nullable: true} + level: {type: string, enum: [documento, decisao, epico, feature, pbi], nullable: true} + metrics: + type: object + required: [latency_ms] + properties: + latency_ms: {type: integer, minimum: 0} + Error: type: object required: [error] diff --git a/frontend/src/api/api_documents.ts b/frontend/src/api/api_documents.ts index f16a253..9b02a71 100644 --- a/frontend/src/api/api_documents.ts +++ b/frontend/src/api/api_documents.ts @@ -12,6 +12,7 @@ export interface ProjectDocument { mime: string | null; tamanho_bytes: number | null; status_processamento: DocumentStatus; + processamento_erro?: string | null; armazenamento_pendente: boolean; autor_id: string | null; autor_nome: string | null; @@ -54,6 +55,7 @@ function parseDocument(value: unknown): ProjectDocument { mime: typeof value.mime === "string" ? value.mime : null, tamanho_bytes: typeof size === "number" ? size : null, status_processamento: value.status_processamento as DocumentStatus, + processamento_erro: typeof value.processamento_erro === "string" ? value.processamento_erro : null, armazenamento_pendente: value.armazenamento_pendente === true, autor_id: typeof value.autor_id === "string" ? value.autor_id : null, autor_nome: typeof value.autor_nome === "string" ? value.autor_nome : null, @@ -108,6 +110,13 @@ export async function removeDocument(projectId: string, documentId: string, sign }); } +export async function retryDocumentProcessing(projectId: string, documentId: string, signal?: AbortSignal): Promise { + await apiRequest(`/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}/retry`, { + method: "POST", + signal: signal ?? AbortSignal.timeout(15_000), + }); +} + export function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1).replace(".", ",")} KB`; diff --git a/frontend/src/assets/styles/garakis-prototype.css b/frontend/src/assets/styles/garakis-prototype.css index fa11fc4..92e8f44 100644 --- a/frontend/src/assets/styles/garakis-prototype.css +++ b/frontend/src/assets/styles/garakis-prototype.css @@ -583,6 +583,17 @@ h2 { color: var(--orange); } +.knowledge-search-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(200px, 240px) auto; + gap: 16px; + align-items: end; +} + +.knowledge-search-controls > button { + min-height: 42px; +} + @media (max-width: 820px) { .top-header { padding: 0 18px; @@ -608,6 +619,12 @@ h2 { border-bottom: 1px solid var(--line); padding-bottom: 16px; } + .knowledge-search-controls { + grid-template-columns: 1fr; + } + .knowledge-search-controls > button { + width: 100%; + } } /* Responsivo: cabeçalho e abas não podem transbordar em telas estreitas */ diff --git a/frontend/src/views/documents/DocumentsView.test.tsx b/frontend/src/views/documents/DocumentsView.test.tsx index 7208304..c5d5fdd 100644 --- a/frontend/src/views/documents/DocumentsView.test.tsx +++ b/frontend/src/views/documents/DocumentsView.test.tsx @@ -54,7 +54,7 @@ it("lista somente os documentos do projeto com metadados e status honestos sobre expect(within(rows[2]).getByText("Disponível no acervo")).toBeInTheDocument(); expect(within(rows[2]).getByText("Não informado")).toBeInTheDocument(); expect(within(rows[3]).getByText("Falha no processamento")).toBeInTheDocument(); - expect(screen.getByText(/depende da S2-01/)).toBeInTheDocument(); + expect(screen.getByText(/processados em segundo plano/)).toBeInTheDocument(); }); it("mostra estado de armazenamento em finalização quando o servidor sinaliza pendência", async () => { @@ -101,15 +101,15 @@ it("Atualizar recarrega a lista e mantém os dados quando a atualização falha" expect(screen.getByText("Disponível no acervo")).toBeInTheDocument(); }); -it("atualiza sozinho enquanto há documento em processamento", async () => { +it("atualiza sozinho enquanto há documento pendente ou em processamento", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); const request = vi.fn() - .mockImplementationOnce(() => listing([doc({ status_processamento: "processando" })])) + .mockImplementationOnce(() => listing([doc({ status_processamento: "pendente" })])) .mockImplementation(() => listing([doc({ status_processamento: "processado" })])); vi.stubGlobal("fetch", request); view(); - await screen.findByText("Processando"); + await screen.findByText("Aguardando ingestão"); await vi.advanceTimersByTimeAsync(10_100); expect(await screen.findByText("Disponível no acervo")).toBeInTheDocument(); }); @@ -163,7 +163,7 @@ it("recusa no cliente formato e tamanho inválidos sem chamar o servidor", async expect(request).toHaveBeenCalledTimes(1); }); -it("envia o arquivo como corpo binário, adiciona à lista e avisa que a indexação depende da S2-01", async () => { +it("envia o arquivo como corpo binário, adiciona à lista e avisa que a indexação será feita em segundo plano", async () => { const created = doc({ id: "d-9", nome: "Nova.txt", extensao: ".txt", tamanho_bytes: 5 }); const request = vi.fn() .mockImplementationOnce(() => listing([])) @@ -177,7 +177,7 @@ it("envia o arquivo como corpo binário, adiciona à lista e avisa que a indexa fireEvent.click(screen.getByRole("button", { name: "Enviar documento" })); await waitFor(() => expect(screen.getByRole("table")).toBeInTheDocument()); - expect(screen.getByText(/foi armazenado\. A indexação do acervo depende da integração da S2-01/)).toBeInTheDocument(); + expect(screen.getByText(/foi armazenado/)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Limpar seleção" })).toBeNull(); const [url, init] = request.mock.calls[1] as [string, RequestInit]; expect(url).toBe("/api/v1/projects/p-1/documents"); @@ -186,6 +186,36 @@ it("envia o arquivo como corpo binário, adiciona à lista e avisa que a indexa expect((init.headers as Record)["Content-Type"]).toBe("application/octet-stream"); }); +it("agenda retry de documento falho e reflete estado pendente enquanto a lista atualiza", async () => { + const failed = doc({ status_processamento: "falha", processamento_erro: "Ollama indisponível." }); + const request = vi.fn() + .mockImplementationOnce(() => listing([failed])) + .mockImplementationOnce(() => new Response(JSON.stringify({ status_processamento: "pendente" }), { status: 202 })) + .mockImplementationOnce(() => listing([doc({ status_processamento: "pendente" })])); + vi.stubGlobal("fetch", request); + view(); + + const retry = await screen.findByRole("button", { name: "Tentar novamente" }); + fireEvent.click(retry); + expect(await screen.findByText(/reprocessamento .* foi agendado/)).toBeInTheDocument(); + expect(await screen.findByText("Aguardando ingestão")).toBeInTheDocument(); + expect(urlOf(request.mock.calls[1])).toBe("/api/v1/projects/p-1/documents/d-1/retry"); + expect((request.mock.calls[1] as [string, RequestInit])[1].method).toBe("POST"); +}); + +it("mostra falha do retry no contexto de processamento, sem rotular como falha de upload", async () => { + const request = vi.fn() + .mockImplementationOnce(() => listing([doc({ status_processamento: "falha" })])) + .mockImplementationOnce(() => json({ error: "indisponível" }, 503)); + vi.stubGlobal("fetch", request); + view(); + + fireEvent.click(await screen.findByRole("button", { name: "Tentar novamente" })); + expect(await screen.findByText("Não foi possível agendar o reprocessamento. Atualize a lista e tente novamente.")).toBeInTheDocument(); + expect(screen.getByText("Falha no reprocessamento")).toBeInTheDocument(); + expect(screen.queryByText("Falha no envio")).toBeNull(); +}); + it("armazenamento pendente após o envio é informado ao usuário", async () => { const request = vi.fn() .mockImplementationOnce(() => listing([])) diff --git a/frontend/src/views/documents/DocumentsView.tsx b/frontend/src/views/documents/DocumentsView.tsx index dd6d924..341e151 100644 --- a/frontend/src/views/documents/DocumentsView.tsx +++ b/frontend/src/views/documents/DocumentsView.tsx @@ -5,6 +5,7 @@ import { formatBytes, listDocuments, removeDocument, + retryDocumentProcessing, uploadDocument, validateSelection, type DocumentLimits, @@ -54,10 +55,12 @@ export function DocumentsView({ const [selection, setSelection] = useState(null); const [selectionError, setSelectionError] = useState(""); const [uploadError, setUploadError] = useState(""); + const [processingError, setProcessingError] = useState(""); const [removalError, setRemovalError] = useState(""); const [notice, setNotice] = useState(""); const [uploading, setUploading] = useState(false); const [removing, setRemoving] = useState(false); + const [retryingId, setRetryingId] = useState(null); const [target, setTarget] = useState(null); const fileInput = useRef(null); const dialog = useRef(null); @@ -103,7 +106,7 @@ export function DocumentsView({ return () => { active = false; controller.abort(); }; }, [projectId]); - const hasProcessing = items.some((item) => item.status_processamento === "processando"); + const hasProcessing = items.some((item) => item.status_processamento === "pendente" || item.status_processamento === "processando"); useEffect(() => { if (!hasProcessing) return; const timer = window.setInterval(() => { void refresh(true); }, 10_000); @@ -169,8 +172,8 @@ export function DocumentsView({ const created = await uploadDocument(projectId, selection); setItems((current) => [created, ...current.filter((item) => item.id !== created.id)]); setNotice(created.armazenamento_pendente - ? `O documento “${created.nome}” foi recebido. O armazenamento está sendo finalizado e a indexação depende da integração da S2-01.` - : `O documento “${created.nome}” foi armazenado. A indexação do acervo depende da integração da S2-01.`); + ? `O documento “${created.nome}” foi recebido. O armazenamento está sendo finalizado e a indexação começará em segundo plano.` + : `O documento “${created.nome}” foi armazenado e será indexado em segundo plano.`); clearSelection(); } catch (error) { setUploadError(describeUploadError(error)); @@ -205,6 +208,25 @@ export function DocumentsView({ } }; + const retryProcessing = async (document: ProjectDocument) => { + if (!projectId || !canWrite || archived || retryingId) return; + setRetryingId(document.id); + setNotice(""); + setProcessingError(""); + try { + await retryDocumentProcessing(projectId, document.id); + setItems((current) => current.map((item) => item.id === document.id + ? { ...item, status_processamento: "pendente", processamento_erro: null } + : item)); + setNotice(`O reprocessamento de “${document.nome}” foi agendado.`); + void refresh(true); + } catch { + setProcessingError("Não foi possível agendar o reprocessamento. Atualize a lista e tente novamente."); + } finally { + setRetryingId(null); + } + }; + if (!projectId) { return (
@@ -230,10 +252,11 @@ export function DocumentsView({ {archived && Projeto arquivado: os documentos ficam disponíveis somente para consulta.} {!canWrite && !archived && Seu perfil pode consultar documentos, mas não pode enviar ou remover arquivos.} - A ingestão e indexação dos documentos depende da S2-01. Enquanto isso, o status permanece como pendente e o conteúdo ainda não aparece nas buscas. + Documentos válidos são processados em segundo plano. O conteúdo fica disponível no acervo após a conclusão da extração e indexação. {notice && {notice}} {selectionError && {selectionError}} {uploadError && {uploadError}} + {processingError && {processingError}} {loadError && ( {loadError}{" "} @@ -320,9 +343,11 @@ export function DocumentsView({ {item.armazenamento_pendente ? Finalizando armazenamento : {status.label}} + {item.processamento_erro &&

{item.processamento_erro}

} {canWrite && !archived && ( + {item.status_processamento === "falha" && } )} diff --git a/frontend/src/views/knowledge/KnowledgeView.test.tsx b/frontend/src/views/knowledge/KnowledgeView.test.tsx new file mode 100644 index 0000000..48b5c2a --- /dev/null +++ b/frontend/src/views/knowledge/KnowledgeView.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { KnowledgeView } from "./KnowledgeView"; + +const json = (value: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(value), { status })); +const projectPage = { items: [{ id: "project-1", nome: "Projeto Alfa", cliente: "Cliente", descricao: "", status: "ativo" }], total: 1, limit: 50, offset: 0 }; +const result = { + id: "chunk-1", project_id: "project-1", project_name: "Projeto Alfa", entity_type: "pbi", entity_id: "pbi-1", + title: "Implementar autenticação", text: "Usar JWT para proteger as rotas.", metadata: { tecnologias_ids: ["tech-1"] }, + source_url: "/projects/project-1/epics/epic-1/features/feature-1/pbis/pbi-1", relevance_score: 0.03, +}; + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +it("renderiza o contrato atual da busca e oferece navegação à origem", async () => { + const request = vi.fn((input: RequestInfo | URL) => String(input).includes("/projects?") + ? json(projectPage) + : json({ items: [result], total: 1 })); + vi.stubGlobal("fetch", request); + render(); + + fireEvent.change(await screen.findByLabelText("Escopo do projeto"), { target: { value: "project-1" } }); + fireEvent.change(screen.getByLabelText("Pesquisar no acervo"), { target: { value: "autenticação JWT" } }); + fireEvent.click(screen.getByRole("button", { name: "Pesquisar" })); + + expect(await screen.findByText("Implementar autenticação")).toBeInTheDocument(); + expect(screen.getByText("Usar JWT para proteger as rotas.")).toBeInTheDocument(); + expect(screen.getByText("Projeto: Projeto Alfa")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Abrir origem" })).toHaveAttribute("href", result.source_url); + expect(request.mock.calls.map(call => String(call[0]))).toContain("/api/v1/search?q=autentica%C3%A7%C3%A3o+JWT&projeto_id=project-1"); +}); + +it("mantém escopo explícito, bloqueia consultas curtas e não conserva resultados antigos ao editar", async () => { + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => String(input).includes("/projects?") + ? json(projectPage) + : json({ items: [result], total: 1 }))); + render(); + await screen.findByLabelText("Escopo do projeto"); + expect(screen.getByRole("button", { name: "Pesquisar" })).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Escopo do projeto"), { target: { value: "project-1" } }); + fireEvent.change(screen.getByLabelText("Pesquisar no acervo"), { target: { value: "jwt" } }); + fireEvent.click(screen.getByRole("button", { name: "Pesquisar" })); + expect(await screen.findByText("Implementar autenticação")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Pesquisar no acervo"), { target: { value: "jwt novo" } }); + expect(screen.queryByText("Implementar autenticação")).not.toBeInTheDocument(); +}); + +it("mostra a mensagem de erro retornada pela API", async () => { + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => String(input).includes("/projects?") + ? json(projectPage) + : json({ error: "Serviço de embeddings indisponível.", code: "EMBEDDING_SERVICE_UNAVAILABLE" }, 503))); + render(); + fireEvent.change(await screen.findByLabelText("Escopo do projeto"), { target: { value: "project-1" } }); + fireEvent.change(screen.getByLabelText("Pesquisar no acervo"), { target: { value: "autenticação" } }); + fireEvent.click(screen.getByRole("button", { name: "Pesquisar" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Serviço de embeddings indisponível."); +}); diff --git a/frontend/src/views/knowledge/KnowledgeView.tsx b/frontend/src/views/knowledge/KnowledgeView.tsx index cab5da4..de401ee 100644 --- a/frontend/src/views/knowledge/KnowledgeView.tsx +++ b/frontend/src/views/knowledge/KnowledgeView.tsx @@ -1,67 +1,94 @@ -import React, { useState, useEffect, useCallback } from "react"; -import { apiRequest } from "../../api/api_auth"; +import React, { useCallback, useEffect, useState } from "react"; +import { ApiError, apiRequest } from "../../api/api_auth"; +import { listProjects, type Project } from "../../api/api_projects"; import { SearchField } from "../common/SearchField"; import "../../assets/styles/garakis-prototype.css"; interface SearchItem { id: string; - projeto_id: string; - projeto_nome?: string; - entidade_tipo: string; - entidade_id: string; - texto: string; - metadados_json: Record; - created_at: string; + project_id: string; + project_name: string; + entity_type: string; + entity_id: string; + title: string | null; + text: string; + metadata: Record; + source_url: string | null; + relevance_score: number; } -interface ProjectOption { - id: string; - nome: string; +function parseSearchItems(value: unknown): SearchItem[] { + if (!value || typeof value !== "object" || !Array.isArray((value as Record).items)) { + throw new Error("A resposta da busca está em formato inesperado."); + } + return ((value as { items: unknown[] }).items).map((item): SearchItem => { + if (!item || typeof item !== "object") throw new Error("A resposta da busca contém uma origem inválida."); + const row = item as Record; + if (typeof row.id !== "string" || typeof row.project_id !== "string" || typeof row.project_name !== "string" + || typeof row.entity_type !== "string" || typeof row.entity_id !== "string" || typeof row.text !== "string" + || (row.title !== null && typeof row.title !== "string") + || (row.source_url !== null && typeof row.source_url !== "string") + || typeof row.relevance_score !== "number" || !Number.isFinite(row.relevance_score) + || (row.metadata !== null && (typeof row.metadata !== "object" || Array.isArray(row.metadata)))) { + throw new Error("A resposta da busca contém uma origem inválida."); + } + return { ...row, metadata: row.metadata ?? {} } as SearchItem; + }); +} + +function describeError(error: unknown): string { + if (error instanceof ApiError && error.details && typeof error.details === "object") { + const message = (error.details as Record).error; + if (typeof message === "string") return message; + } + return error instanceof Error ? error.message : "Não foi possível pesquisar no acervo."; } export const KnowledgeView: React.FC = () => { const [query, setQuery] = useState(""); - const [selectedProject, setSelectedProject] = useState(""); - const [projects, setProjects] = useState([]); + const [selectedProject, setSelectedProject] = useState(""); + const [projects, setProjects] = useState([]); + const [projectsLoading, setProjectsLoading] = useState(true); + const [projectsError, setProjectsError] = useState(null); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [searched, setSearched] = useState(false); + const [error, setError] = useState(null); - // Carrega projetos reais do backend para o filtro de escopo useEffect(() => { - apiRequest("/projects") - .then((res) => res.json()) - .then((data) => { - if (Array.isArray(data.items)) { - setProjects(data.items.map((p: { id: string; nome: string }) => ({ id: p.id, nome: p.nome }))); - } + const controller = new AbortController(); + void listProjects(controller.signal) + .then(page => setProjects(page.projects)) + .catch(cause => { + if (!controller.signal.aborted) setProjectsError(describeError(cause)); }) - .catch(() => { - // Trata erro de rede sem quebrar o componente - }); + .finally(() => { if (!controller.signal.aborted) setProjectsLoading(false); }); + return () => controller.abort(); }, []); - const handleSearch = useCallback(async () => { + const handleSearch = useCallback(async (event?: React.FormEvent) => { + event?.preventDefault(); + if (!selectedProject || query.trim().length < 3 || loading) return; setLoading(true); setSearched(true); + setError(null); + setResults([]); try { - const params = new URLSearchParams(); - if (query.trim()) params.append("q", query.trim()); - if (selectedProject) params.append("projeto_id", selectedProject); - - const res = await apiRequest(`/search?${params.toString()}`); - const data = await res.json(); - setResults(data.items || []); - } catch { - setResults([]); + const params = new URLSearchParams({ q: query.trim(), projeto_id: selectedProject }); + const response = await apiRequest(`/search?${params.toString()}`); + setResults(parseSearchItems(await response.json())); + } catch (cause) { + setError(describeError(cause)); } finally { setLoading(false); } - }, [query, selectedProject]); + }, [query, selectedProject, loading]); - useEffect(() => { - void handleSearch(); - }, [selectedProject, handleSearch]); + const resetSearch = () => { + setResults([]); + setSearched(false); + setError(null); + }; return (
@@ -70,83 +97,80 @@ export const KnowledgeView: React.FC = () => {
BASE INTELIGENTE DE REQUISITOS

Consulta de Conhecimento do Acervo

- Recupere decisões arquiteturais, especificações de PBIs e documentos indexados no repositório PostgreSQL + pgvector. + Pesquise decisões, requisitos e documentos de um projeto. Cada resultado informa sua origem para você abrir o item correspondente.

-
-
+
void handleSearch(event)}> +
{ setQuery(value); resetSearch(); }} + placeholder="Ex.: autenticação JWT, integração PIX, regras de completude" /> -
- +
- -
-
+ {projectsError &&

Não foi possível carregar os projetos: {projectsError}

} + {!projectsLoading && !projectsError && projects.length === 0 &&

Nenhum projeto disponível para pesquisa.

} + - {/* Resultados */} -
- {loading ? ( +
+ {error ? ( +
{error}
+ ) : loading ? (
- Consultando acervo indexado do banco de dados... + Consultando o acervo deste projeto...
) : results.length === 0 ? (
-

{searched ? "Nenhum resultado encontrado" : "Digite um termo para pesquisar"}

+

{searched ? "Nenhum resultado encontrado" : "Pesquise no acervo do projeto"}

{searched - ? "Tente refinar sua busca ou selecione outro projeto no filtro de escopo." - : "Busque por conceitos, tecnologias ou regras de negócio cadastradas nos projetos."} + ? "Tente outros termos. A consulta permanece limitada ao projeto selecionado." + : "Selecione um projeto e digite pelo menos 3 caracteres para começar."}

) : (
- {results.map((item) => ( -
-
-
- {item.entidade_tipo} - {item.projeto_nome && Projeto: {item.projeto_nome}} -
- - {new Date(item.created_at).toLocaleDateString("pt-BR")} - -
- -

- {item.texto} -

- - {item.metadados_json && Object.keys(item.metadados_json).length > 0 && ( -
- Origem: {JSON.stringify(item.metadados_json)} + {results.map(item => { + const title = item.title?.trim() || `Origem do tipo ${item.entity_type}`; + const sourceName = item.metadata.source_name ?? item.metadata.nome_arquivo ?? item.metadata.fonte; + return ( +
+
+
+ {item.entity_type} + {title} +
+ Projeto: {item.project_name}
- )} -
- ))} +

+ {item.text} +

+ {typeof sourceName === "string" &&

Documento de origem: {sourceName}

} + {item.source_url + ? Abrir origem + : Origem sem rota disponível.} +
+ ); + })}
)}
diff --git a/scripts/smoke_document_lifecycle.py b/scripts/smoke_document_lifecycle.py new file mode 100644 index 0000000..a1e392c --- /dev/null +++ b/scripts/smoke_document_lifecycle.py @@ -0,0 +1,229 @@ +"""End-to-end smoke for S2-01/S2-02/S2-06 using synthetic files and a disposable project.""" + +from __future__ import annotations + +import argparse +import html +import io +import json +import os +import re +import sys +import time +import uuid +import zipfile +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode, urlsplit +from urllib.request import Request, urlopen + + +def pdf_bytes(text: str) -> bytes: + safe = text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + stream = f"BT /F1 12 Tf 72 720 Td ({safe}) Tj ET".encode("ascii") + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream", + ] + output = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + offsets = [0] + for number, body in enumerate(objects, 1): + offsets.append(len(output)) + output.extend(f"{number} 0 obj\n".encode() + body + b"\nendobj\n") + xref_offset = len(output) + output.extend(f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode()) + for offset in offsets[1:]: + output.extend(f"{offset:010d} 00000 n \n".encode()) + output.extend(f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode()) + return bytes(output) + + +def docx_bytes(text: str) -> bytes: + escaped = html.escape(text, quote=False) + document_xml = f''' + +{escaped} + +Campo{escaped} +''' + content_types = ''' + + + + +''' + relationships = ''' + + +''' + output = io.BytesIO() + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("[Content_Types].xml", content_types) + archive.writestr("_rels/.rels", relationships) + archive.writestr("word/document.xml", document_xml) + return output.getvalue() + + +def request_json(url: str, cookie: str, method: str = "GET", body: bytes | None = None, + headers: dict[str, str] | None = None) -> tuple[int, object | None]: + request_headers = {"Cookie": cookie, "Accept": "application/json"} + if headers: + request_headers.update(headers) + request = Request(url, data=body, headers=request_headers, method=method) + try: + with urlopen(request, timeout=20) as response: + raw = response.read() + return response.status, json.loads(raw) if raw else None + except HTTPError as error: + # Do not print response bodies: they may contain implementation details. + raise RuntimeError(f"{method} {request.full_url.split('?')[0]} retornou HTTP {error.code}") from None + except (URLError, TimeoutError) as error: + reason = getattr(error, "reason", error) + raise RuntimeError(f"Falha de rede em {method} {request.full_url.split('?')[0]}: {type(reason).__name__}") from None + + +def validate_api_base(value: str) -> str: + try: + parsed = urlsplit(value) + port = parsed.port + except ValueError: + raise ValueError("URL base da API inválida.") from None + if (parsed.scheme != "http" or parsed.hostname not in {"localhost", "127.0.0.1", "::1"} + or parsed.username or parsed.password or parsed.path not in {"", "/"} + or parsed.query or parsed.fragment or port == 0): + raise ValueError("Por segurança, a API deve ser local (localhost/127.0.0.1/::1), sem credenciais ou caminho adicional.") + return value.rstrip("/") + + +def wait_until_processed(api: str, project_id: str, cookie: str, document_id: str, + timeout_seconds: int) -> dict[str, object]: + deadline = time.monotonic() + timeout_seconds + list_url = f"{api}/api/v1/projects/{project_id}/documents?limit=50" + while time.monotonic() < deadline: + status, payload = request_json(list_url, cookie) + if status != 200 or not isinstance(payload, dict): + raise RuntimeError("A listagem de documentos retornou resposta inesperada.") + items = payload.get("items") + item = next((row for row in items if isinstance(row, dict) and row.get("id") == document_id), None) if isinstance(items, list) else None + if item is None: + raise RuntimeError("O documento enviado não apareceu na listagem do projeto.") + state = item.get("status_processamento") + if state == "processado": + return item + if state == "falha": + raise RuntimeError("A ingestão terminou em falha; consulte o estado do documento no sistema.") + if state not in {"pendente", "processando"}: + raise RuntimeError("O documento retornou um estado de processamento desconhecido.") + time.sleep(2) + raise RuntimeError(f"Timeout: o documento não foi processado em {timeout_seconds} segundos.") + + +def search_for_document(api: str, project_id: str, cookie: str, phrase: str, + document_id: str, timeout_seconds: int = 60) -> bool: + query = urlencode({"q": phrase, "projeto_id": project_id, "limit": "50"}) + url = f"{api}/api/v1/search?{query}" + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + status, payload = request_json(url, cookie) + if status != 200 or not isinstance(payload, dict): + raise RuntimeError("A busca retornou resposta inesperada.") + items = payload.get("items") + found = next((row for row in items if isinstance(row, dict) and row.get("entity_id") == document_id), None) if isinstance(items, list) else None + if found: + if found.get("project_id") != project_id: + raise RuntimeError("A busca retornou uma fonte fora do projeto solicitado.") + expected_source = f"/projects/{project_id}/documents" + if found.get("source_url") != expected_source: + raise RuntimeError("A busca retornou um caminho de origem incorreto para o documento.") + return True + time.sleep(2) + return False + + +def run(api: str, project_id: str, cookie: str, timeout_seconds: int) -> None: + api = validate_api_base(api) + fixtures = ( + (".pdf", "application/pdf", lambda marker: pdf_bytes(marker)), + (".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", lambda marker: docx_bytes(marker)), + (".md", "text/markdown", lambda marker: f"# Smoke\n\n{marker}\n".encode("utf-8")), + (".txt", "text/plain", lambda marker: f"Smoke documental.\n\n{marker}\n".encode("utf-8")), + ) + uploaded: list[str] = [] + failures: list[str] = [] + try: + for extension, mime, build in fixtures: + token = uuid.uuid4().hex + # Three natural-language terms give full-text search a stable, unique probe. + marker = f"Quasar Nectario {token}" + filename = f"sinapse-smoke-{token}{extension}" + upload_url = f"{api}/api/v1/projects/{project_id}/documents" + status, payload = request_json( + upload_url, + cookie, + method="POST", + body=build(marker), + headers={"Content-Type": mime, "X-File-Name": quote(filename, safe="")}, + ) + if (status != 201 or not isinstance(payload, dict) or not isinstance(payload.get("id"), str) + or payload.get("projeto_id") != project_id): + raise RuntimeError(f"Upload de {extension} não retornou HTTP 201 e ID do documento.") + document_id = payload["id"] + uploaded.append(document_id) + document = wait_until_processed(api, project_id, cookie, document_id, timeout_seconds) + if document.get("projeto_id") != project_id or document.get("nome") != filename: + raise RuntimeError(f"Escopo ou origem incorretos no documento {extension}.") + if not search_for_document(api, project_id, cookie, marker, document_id): + raise RuntimeError(f"A busca S2-06 não encontrou a fonte recém-indexada ({extension}).") + + delete_url = f"{api}/api/v1/projects/{project_id}/documents/{document_id}" + delete_status, _ = request_json(delete_url, cookie, method="DELETE") + if delete_status != 204: + raise RuntimeError(f"Remoção de {extension} não retornou HTTP 204.") + uploaded.remove(document_id) + search_url = f"{api}/api/v1/search?{urlencode({'q': marker, 'projeto_id': project_id, 'limit': '50'})}" + _, result = request_json(search_url, cookie) + items = result.get("items") if isinstance(result, dict) else None + if isinstance(items, list) and any( + isinstance(row, dict) and row.get("entity_id") == document_id for row in items + ): + raise RuntimeError(f"A fonte {extension} continuou aparecendo na busca após remoção.") + print(f"PASS {extension}: upload, processamento, busca/origem e remoção") + except Exception as error: + failures.append(str(error)) + finally: + for document_id in uploaded: + try: + request_json(f"{api}/api/v1/projects/{project_id}/documents/{document_id}", cookie, method="DELETE") + except Exception: + failures.append(f"Falha na limpeza do documento sintético {document_id}.") + if failures: + for failure in failures: + print(f"FAIL {failure}", file=sys.stderr) + raise SystemExit(1) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--api", default=os.getenv("SINAPSE_API_BASE_URL", "http://localhost:3001"), help="Base URL de uma API local.") + parser.add_argument("--project-id", default=os.getenv("SINAPSE_PROJECT_ID"), help="UUID de projeto descartável no ambiente de teste.") + parser.add_argument("--timeout", type=int, default=600, help="Tempo máximo de processamento por arquivo (segundos).") + parser.add_argument("--confirm-disposable", action="store_true", help="Confirma que o projeto informado é descartável e pertence a um banco de teste.") + args = parser.parse_args() + cookie = os.getenv("SINAPSE_SESSION_COOKIE", "").strip() + if not args.confirm_disposable: + parser.error("Confirme o uso de um projeto/banco descartável com --confirm-disposable.") + if not cookie or not re.fullmatch(r"[0-9a-fA-F-]{36}", args.project_id or ""): + parser.error("Defina SINAPSE_SESSION_COOKIE e SINAPSE_PROJECT_ID (UUID de um projeto descartável).") + if args.timeout < 1 or args.timeout > 1800: + parser.error("--timeout deve ficar entre 1 e 1800 segundos.") + try: + api = validate_api_base(args.api) + except ValueError as error: + parser.error(str(error)) + run(api, args.project_id, cookie, args.timeout) + + +if __name__ == "__main__": + main() From 518e125b7418a4d2d4b8ad963d8b5337be62ba22 Mon Sep 17 00:00:00 2001 From: LoadCG Date: Sat, 3 Oct 2026 01:13:05 -0300 Subject: [PATCH 2/5] docs: link sprint review pull request --- README.md | 2 +- docs/PLANO_FECHAMENTO_PR_UNICO_S2.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 522cfe4..cca10fa 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ A primeira sprint teve como objetivo construir a **base funcional do Sinapse** e ### 📍 Estado atual do trabalho -A Sprint 1 foi concluída em **27/09/2026**. A Sprint 2 está planejada para **05/10 a 25/10/2026**. A implementação candidata de ingestão, busca e avaliação está em [PR de revisão](https://github.com/Galaticos-API/API-4/pulls) e ainda não foi aceita: o baseline encontrou latência acima de 2 s e pendências de relevância (Q008 e Q022). Veja o [registro QA](docs/STATUS_REVISAO_2026-10-02.md) para evidências, limites e próximos passos. +A Sprint 1 foi concluída em **27/09/2026**. A Sprint 2 está planejada para **05/10 a 25/10/2026**. A implementação candidata de ingestão, busca e avaliação está em [PR de revisão](https://github.com/Galaticos-API/API-4/pull/44) e ainda não foi aceita: o baseline encontrou latência acima de 2 s e pendências de relevância (Q008 e Q022). Veja o [registro QA](docs/STATUS_REVISAO_2026-10-02.md) para evidências, limites e próximos passos. --- diff --git a/docs/PLANO_FECHAMENTO_PR_UNICO_S2.md b/docs/PLANO_FECHAMENTO_PR_UNICO_S2.md index b022660..b26d58a 100644 --- a/docs/PLANO_FECHAMENTO_PR_UNICO_S2.md +++ b/docs/PLANO_FECHAMENTO_PR_UNICO_S2.md @@ -3,7 +3,7 @@ **Criado:** 02/10/2026 **Revisão do plano:** 03/10/2026 **Objetivo:** concluir e validar S2-01, S2-02, S2-06 e S2-17, depois enviá-las juntas em um único PR para `main`. -**Estado revisado em:** 03/10/2026. Branch `codex/s2-17-ptbr-search-evaluation`, baseada em `origin/main` `4dc3033`. PR de rascunho será aberto a pedido do usuário, com os gates de latência e relevância explicitamente reprovados; não representa aprovação para merge nem aceite Scrum. +**Estado revisado em:** 03/10/2026. Branch `codex/s2-17-ptbr-search-evaluation`, baseada em `origin/main` `4dc3033`. PR de rascunho aberto: https://github.com/Galaticos-API/API-4/pull/44, com os gates de latência e relevância explicitamente reprovados; não representa aprovação para merge nem aceite Scrum. > **Nota da revisão:** este é o único plano operacional. `PLANO_CONTINUACAO_QA_S2.md` e os relatórios anteriores são registros históricos; não usar suas afirmações de aprovação quando divergirem deste documento e das evidências mais recentes. Os passos abaixo são gates de fechamento propostos, não uma redefinição automática do DoD do Trello. From c2d7ebd2b7419f44985fee9447c2c7a8d1f9a8a2 Mon Sep 17 00:00:00 2001 From: LoadCG Date: Sat, 3 Oct 2026 01:14:28 -0300 Subject: [PATCH 3/5] ci: provide placeholder token for compose validation --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ea3aff..49d6e72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,7 @@ jobs: - name: Validate Docker Compose configuration run: | cp .env.example .env - docker compose config --quiet + DOCUMENT_INGESTION_TOKEN=ci-validation-placeholder-32-characters docker compose config --quiet validate-backend: name: Build & Typecheck Backend (Node.js) From dda762c67e0ce899039afcfc75062099e96aa1ec Mon Sep 17 00:00:00 2001 From: LoadCG Date: Sat, 3 Oct 2026 01:16:40 -0300 Subject: [PATCH 4/5] test(documents): keep S2-05 purge outside scope --- backend/src/modules/documents/documents.repository.db.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/modules/documents/documents.repository.db.test.ts b/backend/src/modules/documents/documents.repository.db.test.ts index 5bbf625..6900b04 100644 --- a/backend/src/modules/documents/documents.repository.db.test.ts +++ b/backend/src/modules/documents/documents.repository.db.test.ts @@ -310,7 +310,7 @@ test("S1-19/S1-22: documentos, auditoria e outbox no PostgreSQL", { skip: !proce await archiver.query("COMMIT"); await assert.rejects(removal, ArchiveConflict); assert.equal((await repo.findById(archivedProject, archivedDocument))?.id, archivedDocument); - assert.equal((await pool.query("SELECT count(*)::int AS total FROM chunk WHERE id=$1", [archivedChunk])).rows[0].total, 0, "arquivar o projeto expurga seus trechos do acervo"); + assert.equal((await pool.query("SELECT count(*)::int AS total FROM chunk WHERE id=$1", [archivedChunk])).rows[0].total, 1, "o expurgo ao arquivar pertence à S2-05 e fica fora deste PR"); assert.equal((await pool.query("SELECT count(*)::int AS total FROM auditoria WHERE entidade_id=$1 AND acao='REMOVER_DOCUMENTO'", [archivedDocument])).rows[0].total, 0); } catch (error) { await archiver.query("ROLLBACK").catch(() => undefined); @@ -381,7 +381,7 @@ test("S1-19/S1-22: documentos, auditoria e outbox no PostgreSQL", { skip: !proce id: pending, projeto_id: project, nome: "pendente.txt", caminho: `${project}/${pending}`, status_processamento: "processando", mime: "text/plain", extensao: ".txt", processamento_lease_id: randomUUID(), }, [{ chunk_index: 0, text: "conteúdo que não pode ser republicado", embedding: Array(1024).fill(0.1), metadata: {} }]), /Projeto arquivado/); - assert.equal((await pool.query("SELECT count(*)::int AS total FROM chunk WHERE projeto_id=$1", [project])).rows[0].total, 0); + assert.equal((await pool.query("SELECT count(*)::int AS total FROM chunk WHERE entidade_tipo='documento' AND entidade_id=$1", [pending])).rows[0].total, 0, "a ingestão antiga não deve repovoar o documento"); }); } finally { await pool.query("DELETE FROM evento_integracao WHERE chave_idempotencia = ANY($1::text[])", [[removalEventKey(indexed), removalEventKey(plain)]]); From 97b63551ac2444cc0f7c83ca52e08f763b4359f4 Mon Sep 17 00:00:00 2001 From: LoadCG Date: Sat, 3 Oct 2026 01:18:47 -0300 Subject: [PATCH 5/5] test(search): require project scope in E2E contract --- e2e/tests/flows.e2e.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/tests/flows.e2e.mjs b/e2e/tests/flows.e2e.mjs index fdbaa30..a280cdc 100644 --- a/e2e/tests/flows.e2e.mjs +++ b/e2e/tests/flows.e2e.mjs @@ -189,7 +189,7 @@ test("Autorização e isolamento: perfis, sessão e itens de outro projeto", asy const cross = await api(`/projects/${project.id}/backlog-search?q=${encodeURIComponent("credenciais")}`, { token: po.token }); assert.ok(!cross.json.items.some((item) => item.id === pbi.id), "item do outro projeto nunca aparece na busca deste"); assert.equal((await api("/search?projeto_id=nao-uuid", { token: po.token })).status, 400); - assert.equal((await api("/search?q=teste", { token: po.token })).status, 200); + assert.equal((await api("/search?q=teste", { token: po.token })).status, 400, "busca do acervo exige escopo explícito de projeto"); assert.equal((await api("/auth/me")).status, 401); assert.equal((await api("/auth/me", { token: "token-invalido" })).status, 401); assert.equal((await api("/admin/stats", { token: dev.token })).status === 200, false);