diff --git a/.env.example b/.env.example index 274b9fc..16a9656 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,12 @@ N8N_ENCRYPTION_KEY=sinapse-shared-dev-encryption-key-2026 # ATENÇÃO: NUNCA comite a chave real no repositório! N8N_API_KEY= +# Token compartilhado entre n8n e backend para o endpoint server-to-server +# POST /api/v1/projects/:p/documents/:d/chunks (pipeline de ingestão de documentos). +# No n8n: configure a credencial "Header Auth" com Name=Authorization, +# Value="Bearer ". Em produção, troque por um segredo aleatório. +N8N_INGEST_TOKEN=sinapse-dev-ingest-token + # --- Runtime de IA e Embeddings Locais (Ollama) --- OLLAMA_PORT=11434 OLLAMA_BASE_URL=http://localhost:11434 @@ -37,24 +43,50 @@ 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 # Limite rígido de arquivos elegíveis inventariados pelo RepoAnalyzer. MAX_FILES=1000 +ANALYZER_MAX_ACTIVE_RUNS=2 +ANALYZER_MAX_QUEUED_RUNS=6 +ANALYZER_WORKSPACE_QUOTA_MB=2048 +MAX_REPO_SIZE_MB=300 +ANALYZER_CLONE_MEMORY_MB=512 +ANALYZER_CLONE_TIMEOUT_SECONDS=300 # --- Documentos enviados (S1-19/S1-22) --- # Limite de tamanho por arquivo, aplicado no backend e informado ao cliente DOCUMENT_MAX_SIZE_MB=20 -# Webhook do n8n que recebe o evento document.removed (vazio mantém o evento pendente) -DOCUMENT_EVENTS_WEBHOOK_URL= +# Webhooks dos workflows n8n versionados; ative ambos antes de processar documentos. +DOCUMENT_EVENTS_WEBHOOK_URL=http://n8n:5678/webhook/sinapse-document-removed +DOCUMENT_INGEST_WEBHOOK_URL=http://n8n:5678/webhook/sinapse-ingest # --- Aplicações e Microsserviços --- 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 TZ=America/Sao_Paulo + +# Token compartilhado entre o backend Node e o ai-service Python (header +# X-Service-Token). O compose exige esta variavel preenchida; sem valor o +# `docker compose up` falha com "Configure AI_SERVICE_TOKEN in .env". +# +# Em DEV, o valor abaixo basta para subir a stack localmente. Em PROD, +# rotacione para um segredo aleatorio (ex.: +# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# ) e NUNCA versione o valor real. +AI_SERVICE_TOKEN=sinapse-dev-ai-service-token diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ea3aff..391c6fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,54 +13,6 @@ on: - develop jobs: - validate-seed: - name: Validate curated seed on PostgreSQL - runs-on: ubuntu-latest - services: - postgres: - image: pgvector/pgvector:pg16 - env: - POSTGRES_USER: seed_test - POSTGRES_PASSWORD: seed_test_only - POSTGRES_DB: sinapse_seed_test - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U seed_test -d sinapse_seed_test" - --health-interval 5s --health-timeout 5s --health-retries 10 - env: - NODE_ENV: test - POSTGRES_HOST: localhost - POSTGRES_PORT: 5432 - POSTGRES_USER: seed_test - POSTGRES_PASSWORD: seed_test_only - POSTGRES_DB: sinapse_seed_test - SEED_TEST_DATABASE_URL: postgresql://seed_test:seed_test_only@localhost:5432/sinapse_seed_test - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: npm - cache-dependency-path: backend/package-lock.json - - run: npm ci - working-directory: backend - - name: Build backend scripts used by seed validation - run: npm run build - working-directory: backend - - run: npm run migrate - working-directory: backend - - run: npm run seed:validate - working-directory: backend - - run: npm run test:seed - working-directory: backend - - name: Test archive cascade on PostgreSQL - run: node --import tsx --test src/modules/projects/projects.archive.db.test.ts src/modules/projects/hierarchy-archive.db.test.ts src/modules/projects/projects.backlog-tree.db.test.ts src/database/migration-010.db.test.ts src/database/migration-011.db.test.ts src/database/migration-012.db.test.ts src/modules/documents/documents.repository.db.test.ts src/modules/chat/chat.repository.db.test.ts src/modules/backlog-search/backlog-search.db.test.ts src/modules/decisions/decisions.db.test.ts src/database/migration-013.db.test.ts - working-directory: backend - env: - ARCHIVE_TEST_DATABASE_URL: postgresql://seed_test:seed_test_only@localhost:5432/sinapse_seed_test - BACKLOG_TREE_TEST_DATABASE_URL: postgresql://seed_test:seed_test_only@localhost:5432/sinapse_seed_test - validate-environment: name: Validate Workflows and Docker Setup runs-on: ubuntu-latest @@ -86,7 +38,7 @@ jobs: - name: Validate Docker Compose configuration run: | cp .env.example .env - docker compose config --quiet + AI_SERVICE_TOKEN=ci-compose-validation-only DOCUMENT_INGESTION_TOKEN=ci-validation-placeholder-32-characters docker compose config --quiet validate-backend: name: Build & Typecheck Backend (Node.js) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 19b875c..efa1d9e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -45,6 +45,9 @@ jobs: POSTGRES_USER: e2e POSTGRES_PASSWORD: e2e_only POSTGRES_DB: sinapse_e2e_test + E2E_DATABASE_URL: postgresql://e2e:e2e_only@localhost:5432/sinapse_e2e_test + AI_SERVICE_URL: http://127.0.0.1:9 + REPO_ANALYZER_URL: http://127.0.0.1:9 E2E_API_URL: http://localhost:3001/api/v1 E2E_APP_URL: http://localhost:5173 E2E_CHROME_PATH: /usr/bin/google-chrome @@ -65,7 +68,7 @@ jobs: run: | export DOCUMENT_STORAGE_DIR="$RUNNER_TEMP/documents" mkdir -p "$DOCUMENT_STORAGE_DIR" - (cd backend && nohup npx tsx src/index.ts > "$RUNNER_TEMP/backend.log" 2>&1 &) + (cd backend && nohup env NODE_ENV=test node --import tsx ../e2e/server.mts > "$RUNNER_TEMP/backend.log" 2>&1 &) (cd frontend && nohup npx vite --port 5173 --strictPort > "$RUNNER_TEMP/frontend.log" 2>&1 &) for i in $(seq 1 30); do curl -fs http://localhost:3001/health > /dev/null && curl -fs http://localhost:5173 > /dev/null && exit 0 diff --git a/.github/workflows/validate-s105.yml b/.github/workflows/validate-s105.yml deleted file mode 100644 index c5ec1f5..0000000 --- a/.github/workflows/validate-s105.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Validate S1-05 consolidation -on: - pull_request: - push: - branches: [main] -jobs: - postgres-compatibility: - runs-on: ubuntu-latest - services: - postgres: - image: pgvector/pgvector:pg16 - env: - POSTGRES_USER: s105_test - POSTGRES_PASSWORD: s105_test_only - POSTGRES_DB: sinapse_s105_test - ports: [5432:5432] - options: >- - --health-cmd "pg_isready -U s105_test -d sinapse_s105_test" - --health-interval 5s --health-timeout 5s --health-retries 10 - env: - NODE_ENV: test - S105_TEST_DATABASE_URL: postgresql://s105_test:s105_test_only@localhost:5432/sinapse_s105_test - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: npm - cache-dependency-path: backend/package-lock.json - - run: npm ci - working-directory: backend - - run: npm run test:integration:s105 - working-directory: backend 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/IMPLEMENTACAO_PIPELINE_INGESTAO.md b/IMPLEMENTACAO_PIPELINE_INGESTAO.md new file mode 100644 index 0000000..da33d5e --- /dev/null +++ b/IMPLEMENTACAO_PIPELINE_INGESTAO.md @@ -0,0 +1,125 @@ +# Pipeline de ingestão de documentos — implementação e correções (S2-01/02/06/17) + +Relatório técnico consolidado do trabalho feito na branch +`feature/s2-02-n8n.embedding`: unificação de três implementações paralelas +(S2-02/n8n, S2-01/worker do Giovanni, S2-06/S2-17 da branch `codex`), +correção dos bugs reais que impediam o pipeline de funcionar ponta a ponta, +e a tela local de observabilidade que substitui o editor do n8n. + +Para o passo a passo de **como rodar e testar**, ver [`TESTE_INGESTAO.md`](TESTE_INGESTAO.md). +Este documento aqui é o **porquê** e o **o quê**. + +## Arquitetura final + +``` +Upload (frontend, multipart/form-data) + → backend grava documento (status_processamento = 'pendente') + → DocumentIngestionWorker (tick a cada 15s) reivindica com lease + → POST ai-service:8000/documents/process + headers: X-Service-Token + X-Document-Ingestion-Token + body: { document_id, project_id, filename, content_base64 } + → ai-service extrai texto (PDF/DOCX/MD/TXT), faz chunking, + gera embeddings via Ollama (bge-m3, 1024 dimensões) + → backend persiste os chunks em `chunk` (vector(1024) + pgvector) + e marca o documento como 'processado' — tudo numa transação + → /admin/ingestion mostra o estado da fila em tempo real + → /api/v1/search faz busca híbrida (full-text + similaridade vetorial) +``` + +O workflow do n8n (`n8n/workflows/*.json`) deixou de ser o canal oficial. +Ele continua versionado e funcional como **ferramenta de debug manual** +(dispara via `curl`, dá pra acompanhar nó a nó no editor), mas o caminho +real que roda em produção é só o worker acima — sem n8n no meio. + +## O que foi implementado + +### Unificação de três branches paralelas +- **S2-02** (esta branch): pipeline original via webhook do n8n. +- **S2-01** (`origin/s2-01-pipeline-assincrono-documentos`, Giovanni): worker + assíncrono com estados `pendente/processando/processado/falha`, lease, + retry e diagnóstico de erro. +- **codex/s2-17-ptbr-search-evaluation** (PR #44): reimplementação de + S2-01/02 do zero + busca híbrida real (S2-06) + bateria de avaliação de + busca em português (S2-17). + +Mesclados nessa ordem (S2-01 → codex), resolvendo conflitos de arquitetura +(ver commits `875b276` e `cd689ad`). Decisão de design: adotada a +arquitetura "worker como canal canônico" da codex — o backend chama o +ai-service direto, sem depender do n8n estar no ar. + +### Endpoints novos no ai-service +- `POST /documents/process` — endpoint oficial (fluxo S2-01): recebe o + arquivo em base64, extrai texto conforme a extensão, faz chunking e + gera embeddings, tudo numa chamada. Autenticado com + `X-Document-Ingestion-Token` (mínimo 32 caracteres). +- `POST /chunk` e `POST /embed` — endpoints de debug para o workflow do + n8n chamar passo a passo (chunking separado de embedding). Não fazem + parte do fluxo oficial. + +### Endpoint novo no backend +- `POST /api/v1/projects/:projectId/documents/:documentId/chunks` — + recebe chunks já processados (usado pelo workflow do n8n em modo debug). + Autenticado com `Authorization: Bearer `, separado da + sessão de usuário humano. + +### Tela de observabilidade (`/admin/ingestion`) +Como o n8n saiu do caminho principal, perdemos a visibilidade visual que o +editor dava. Reconstruída dentro da aplicação: +- `GET /api/v1/admin/ingestion` (backend) — snapshot agregado: contagens + por status, documentos ativos, falhas recentes. +- `IngestionObservabilityView.tsx` (frontend) — 4 contadores grandes, + pipeline visual animado por documento (Recebido → Processando → + Indexado), seção de falhas com botão de reprocessar. Polling a cada 5s. + +### Bootstrap do admin local +`backend/scripts/bootstrap-admin.mts` (`npm run bootstrap:admin`) — cria +ou promove o usuário admin de teste (`admin@sinapse.local` / `Admin@123`), +reaproveitando o `hashPassword()` real do backend. Resolve o impasse de +"cadastro público só cria dev, promover a admin exige sessão de admin" sem +precisar de SQL manual. + +### Setup nativo sem Docker Desktop/WSL2 +`backend/scripts/apply-native-no-vector.mjs` + seção dedicada em +`TESTE_INGESTAO.md`: todo o pipeline (Postgres, Ollama, backend, frontend, +ai-service) validado rodando nativo no Windows, sem Docker — útil para +quem não tem WSL2 disponível. + +## Bugs encontrados e corrigidos + +| # | Sintoma | Causa raiz | Correção | Commit | +|---|---|---|---|---| +| 1 | Upload pela UI nunca disparava o n8n | Frontend mandava o arquivo como `application/octet-stream` cru; backend (após mudar pra multer) exige `multipart/form-data` | `uploadDocument` monta `FormData`; `apiRequest` não força `Content-Type` quando o body é `FormData` | `f657380` | +| 2 | n8n recebia `401` em `/chunk` e `/embed` do ai-service | Workflow usava uma única credencial "Header Auth" pra dois endpoints com headers diferentes (`X-Service-Token` vs `Authorization: Bearer`) | Workflow lê os dois tokens direto de `$env.AI_SERVICE_TOKEN` / `$env.N8N_INGEST_TOKEN`, expostos no container do n8n — sem credencial manual na UI | `12530e8` | +| 3 | n8n caía no nó "Payload inválido" mesmo com tudo configurado certo | **Dupla ingestão**: o backend chamava o webhook do n8n (payload camelCase) *e* o worker da S2-01 chamava o mesmo webhook via `DOCUMENT_INGEST_WEBHOOK_URL` (payload snake_case + base64) — o n8n só entendia um dos dois formatos | Removida a chamada direta do `documents.service.ts` ao n8n; só o worker dispara, e por padrão vai direto pro ai-service (`DOCUMENT_INGEST_WEBHOOK_URL` vazio) | `2b6d9e8` | +| 4 | `docker compose up` falhava com `Configure AI_SERVICE_TOKEN/DOCUMENT_INGESTION_TOKEN in .env` | `.env.example` definia essas variáveis como obrigatórias (`:?`) mas as deixava vazias | Defaults de dev preenchidos no `.env.example`, com instrução de rotacionar em produção | `f74d640` | +| 5 | Arquivo gravado pelo backend ficava ilegível para o container do n8n | Backend salvava com `mode: 0o600` (só o dono lê); container roda como root, n8n roda como usuário `node` (não-root) — sem pgvector/Docker isso nem chega a ser testado, mas quebra o modo debug via n8n | `writeFile` com `mode: 0o644` | `64e6f0d` | +| 6 | **Todo documento falhava com `401` no ai-service**, mesmo com tokens corretos no `.env` | `HttpDocumentIngestionClient.process()` (worker oficial da S2-01, veio da branch codex) só mandava `X-Document-Ingestion-Token`; esqueceu `X-Service-Token`, exigido pelo middleware global do ai-service em *toda* rota — bug real, afeta Docker também, não só ambiente local | Usa o mesmo helper `serviceHeaders()` que `chat.service.ts`/`repo-analyses.service.ts` já usavam corretamente | `b1c7a60` | +| 7 | Geração de embedding falhava/travava de forma intermitente (`wsarecv: conexão forçada a cancelar`) | **Específico de ambiente local**: duas instâncias de `ollama serve` concorrendo pela porta 11434 (o instalador do Ollama já sobe uma via app de bandeja) | Matar todas as instâncias e subir uma única com `OLLAMA_KEEP_ALIVE=24h` | documentado em `TESTE_INGESTAO.md` | + +Os bugs #1, #2, #3, #4, #5 e #6 afetam **qualquer ambiente** (Docker +inclusive) — não são específicos de rodar sem Docker. O #7 é específico de +quem roda Ollama nativo no Windows. + +## Limitações conhecidas + +- **pgvector não tem pacote nativo pro Windows.** Quem roda sem Docker + precisa do shim documentado em `TESTE_INGESTAO.md` (`real[]` no lugar de + `vector`, não commitável) — e mesmo assim a busca híbrida vetorial + (`/api/v1/search`) não funciona nesse modo, só o full-text. Com o + Docker do time (imagem `pgvector/pgvector:pg16`), tudo funciona normal. +- O workflow do n8n em `n8n/workflows/` não é mais testado automaticamente + (sem CI cobrindo o fluxo de debug) — validação é manual, via `curl`, + quando necessário. + +## Como validar + +Ver [`TESTE_INGESTAO.md`](TESTE_INGESTAO.md) — cobre os dois modos (Docker +e nativo), checklist de troubleshooting, e os comandos de avaliação da +busca (S2-17). + +Verificação automatizada: +```bash +cd backend && npx tsc --noEmit && npm test # 313/313 +cd frontend && npx tsc -b && npm test # 210/210 +cd backend && npm run audit:security # 0 vulnerabilidades +``` diff --git a/README.md b/README.md index 4a0d131..6e80917 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/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. + --- ## 🖥️ Conheça o Sinapse @@ -229,6 +233,8 @@ Permite registrar decisões relacionadas aos elementos do projeto, mantendo o co Documentos podem ser associados aos projetos e posteriormente consultados ou removidos. +📖 [Guia completo de upload de documentos](docs/DOCUMENT_UPLOAD.md) + --- ### 👥 Usuários e permissões @@ -289,6 +295,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 +307,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 @@ -426,3 +436,8 @@ O **Sinapse** é um **Projeto de Aprendizagem Interdisciplinar (API)** desenvolv **Grupo Galáticos · Fatec São José dos Campos · 2026** + + +### Segredo da comunicação interna + +Antes de iniciar o Compose, configure `AI_SERVICE_TOKEN` em `.env` com um segredo aleatório (por exemplo, 32 bytes gerados com `crypto.randomBytes` do Node). Não versione o valor. O mesmo segredo é usado pelo backend e pelo serviço Python. A porta 8000 não é publicada; acessos ao Python passam pelo backend e pelo workflow autenticado descrito em [n8n/README.md](n8n/README.md). diff --git a/TESTE_INGESTAO.md b/TESTE_INGESTAO.md new file mode 100644 index 0000000..166ebba --- /dev/null +++ b/TESTE_INGESTAO.md @@ -0,0 +1,309 @@ +# Teste do pipeline de ingestão (S2-01 + S2-02 + observabilidade local) + +Guia prático pós-merge do PR #44 (`codex/s2-17-ptbr-search-evaluation`). O +pipeline agora roda **sem** n8n no caminho oficial — o worker da S2-01 +puxa os documentos pendentes, chama o ai-service direto e persiste os +chunks. A observabilidade visual (que antes dependia do editor do n8n) +foi reconstruída dentro da própria aplicação, em `/admin/ingestion`. + +## Arquitetura em três linhas + +1. **Upload** no frontend grava o documento com `status_processamento = 'pendente'`. +2. **Worker** no backend (`startDocumentsBackgroundWorker` em `index.ts`) + roda a cada ciclo: reivindica o próximo pendente com lease, chama + `POST ai-service:8000/documents/process` (header `X-Document-Ingestion-Token`), + recebe os chunks com embeddings de 1024d e persiste em `chunk` dentro da + mesma transação que marca o documento como `processado`. Falhas são + capturadas em `processamento_erro` + `processamento_tentativas`. +3. **Tela `/admin/ingestion`** (admin) mostra em tempo real a fila: + contagens por estado, documentos ativos, falhas recentes, com pipeline + visual (Recebido → Processando → Indexado) e botão de "Reprocessar" + para itens em falha. Faz polling a cada 5 s. + +O workflow do n8n continua disponível como **ferramenta opcional de debug +manual** — tudo que se precisa pra validar fluxo normal já está na UI. + +## Modo nativo (sem Docker Desktop / sem WSL2) + +Se a máquina não tem WSL2 (Docker Desktop no Windows Home exige WSL2, não +tem alternativa via Hyper-V), dá pra rodar o pipeline inteiro nativo. Testado +e validado ponta a ponta nesta sessão. Resumo das pegadinhas encontradas: + +### O que instalar + +```powershell +winget install --id Ollama.Ollama --source winget +winget install --id Python.Python.3.11 --source winget +winget install --id PostgreSQL.PostgreSQL.16 --source winget +``` + +Node já precisa estar instalado (qualquer LTS recente). Depois: + +```bash +cd backend && npm install +cd ../frontend && npm install +cd ../ai-service && python -m venv .venv && ./.venv/Scripts/pip install -r requirements.txt +``` + +### Senha do Postgres (instalador silencioso via winget) + +O instalador EDB via `winget --silent` usa `postgres` como senha do +superusuário sem perguntar. Crie o role/banco do projeto: + +```bash +psql -h localhost -U postgres -d postgres -c "CREATE ROLE sinapse LOGIN PASSWORD 'sinapse_dev_password' SUPERUSER;" +psql -h localhost -U postgres -d postgres -c "CREATE DATABASE sinapse OWNER sinapse;" +``` + +### Sem pgvector (bloqueio real, sem solução nativa simples) + +PostgreSQL nativo no Windows **não tem pacote de pgvector**. A extensão só +vem pronta na imagem Docker `pgvector/pgvector:pg16`, ou compilando do zero +com Visual Studio Build Tools. Isso afeta duas coisas: + +1. **`CREATE EXTENSION vector`** falha nas migrations — use o script + `backend/scripts/apply-native-no-vector.mjs`, que aplica `init.sql` + + todas as migrations com um *shim* (remove a extensão, troca + `vector(1024)` por `real[]`, remove índices HNSW): + + ```bash + cd backend && node scripts/apply-native-no-vector.mjs + ``` + +2. **O INSERT de chunks usa `$N::vector`** (em `documents.repository.ts` e + `chunks.service.ts`). Esse cast falha porque o tipo `vector` não existe + de verdade. Para persistir chunks localmente, troque temporariamente + `::vector` → `::real[]` e o literal `[a,b,c]` → `{a,b,c}` nesse INSERT. + **Nunca commite essa troca** — no Docker do time, com pgvector de + verdade, o código correto é `::vector`. É puramente uma muleta local. + + Consequência: a busca híbrida (`/api/v1/search`) não funciona nesse modo + (o `embedding <=> $3::vector` também precisa do tipo real) — ela dá erro + mesmo com o shim de persistência. Tudo o resto (upload, worker, extração, + chunking, embeddings reais, observabilidade) funciona normalmente. + +### `.env` para modo nativo + +Use `localhost` em vez dos nomes de serviço do Docker (`backend`, `n8n`, +`ai-service`) e gere um `DOCUMENT_INGESTION_TOKEN` de 32+ caracteres: + +```env +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +AI_SERVICE_URL=http://localhost:8000 +OLLAMA_BASE_URL=http://localhost:11434 +DOCUMENT_STORAGE_DIR=storage/documents +DOCUMENT_INGEST_WEBHOOK_URL= +AI_SERVICE_TOKEN= +DOCUMENT_INGESTION_TOKEN= +``` + +### Ollama: nunca rode duas instâncias + +O instalador do Ollama registra um app de bandeja que já sobe `ollama serve` +sozinho. Se você também rodar `ollama serve` manualmente, as duas brigam +pela porta 11434 — uma delas vence o bind, mas o processo de inferência por +trás pode ficar instável e derrubar a conexão no meio de uma chamada de +embedding (erro `wsarecv: conexão forçada a cancelar pelo host remoto`). +Confira antes de subir: + +```powershell +Get-Process -Name "ollama*" +``` + +Se houver mais de um `ollama.exe`/`ollama app.exe`, mate todos e suba só um: + +```powershell +Get-Process -Name "ollama*" | Stop-Process -Force +``` +```bash +OLLAMA_KEEP_ALIVE=24h ollama serve +``` + +### Subindo os três serviços + +```bash +cd backend && npm run dev # porta 3001 +cd ai-service && ./.venv/Scripts/python -m uvicorn main:app --port 8000 +cd frontend && npm run dev # porta 5173 +``` + +### Criando o primeiro admin + +Cadastro público só cria `dev` — criar `po`/`admin` exige já estar +autenticado como admin, de propósito. Use o script oficial (idempotente, +reaproveita o `hashPassword` real do backend, então o login funciona de +primeira): + +```bash +cd backend && npm run bootstrap:admin +``` + +Cria (ou promove, se já existir) o admin de teste padrão: + +- **E-mail:** `admin@sinapse.local` +- **Senha:** `Admin@123` + +Para outro e-mail/senha: `npx tsx scripts/bootstrap-admin.mts --email=... --password=... --nome="..."`. +Rodar de novo é seguro — se a conta já existir só garante `role='admin'` +sem mexer na senha (a menos que passe `--force-password`). + +## 0. Pré-requisitos (modo Docker) + +- Docker Desktop rodando. +- Ollama **no host** (fora do Docker, conforme AGENTS.md) com o modelo + `bge-m3`: + + ```bash + ollama pull bge-m3 + ollama list + ``` + +- `.env` na raiz. Em dev basta `cp .env.example .env`. O compose exige + três variáveis preenchidas (há defaults no `.env.example`): + + ```env + AI_SERVICE_TOKEN=sinapse-dev-ai-service-token # backend <-> ai-service + DOCUMENT_INGESTION_TOKEN=…minimum 32 chars… # worker -> ai-service /documents/process + N8N_INGEST_TOKEN=sinapse-dev-ingest-token # opcional, só para debug via n8n + ``` + + Gere um `DOCUMENT_INGESTION_TOKEN` real com: + + ```bash + node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" + ``` + +## 1. Subir a stack + +```bash +docker compose up -d --build +docker compose ps +``` + +Sanity-check: + +```bash +curl http://localhost:3001/health # backend +curl http://localhost:8000/health # ai-service (ollama: "connected") +``` + +## 2. Testar via UI (fluxo oficial) + +0. Sem usuário ainda? Crie o admin de teste padrão (idempotente): + + ```bash + cd backend && npm run bootstrap:admin + ``` + + - **E-mail:** `admin@sinapse.local` + - **Senha:** `Admin@123` + +1. Entre no frontend (`http://localhost:5173`), logue como `admin` ou `po`. +2. Abra um projeto ativo e vá na aba **Documentos**. +3. Envie um PDF, DOCX, MD ou TXT (até 20 MB). +4. A linha do documento aparece com badge `Aguardando ingestão` (worker ainda + não pegou) ou `Processando` (worker começou). A tabela auto-atualiza + enquanto houver documento em andamento. +5. Quando terminar, o badge vira `Disponível no acervo` (verde). +6. Se der falha, aparece o motivo no card e um botão **Reprocessar** que + recoloca o documento como `pendente`. + +### Acompanhar em tempo real (`/admin/ingestion`) + +Em outra aba, logado como `admin`: + +**→ http://localhost:5173/admin/ingestion** (ou menu **Admin → Pipeline de ingestão**). + +- A tela mostra 4 contadores (`Pendente`, `Processando`, `Processado`, `Falha`), + a lista de documentos **em andamento** com barra de progresso animada, + e uma seção de **falhas recentes** com botão de reprocessar. +- Polling a cada 5 s. O timestamp "Atualizado em" confirma o refresh. + +Isso substitui integralmente o editor do n8n para debug. + +## 3. Confirmar no banco (opcional) + +```bash +docker exec -it sinapse-postgres psql -U sinapse -d sinapse <<'SQL' +SELECT id, nome, status_processamento, processamento_tentativas, + processamento_erro, updated_at + FROM documento + ORDER BY updated_at DESC + LIMIT 10; + +SELECT entidade_id, LEFT(texto, 60) AS preview, vector_dims(embedding) AS dim + FROM chunk + WHERE entidade_tipo = 'documento' + ORDER BY created_at DESC + LIMIT 10; +SQL +``` + +Critérios: +- `status_processamento = 'processado'` no último documento enviado +- `vector_dims(embedding) = 1024` (bge-m3) nos chunks correspondentes + +## 4. Buscar no acervo (S2-06, busca híbrida) + +Via UI: **Conhecimento → busca** (precisa escolher o projeto e digitar 3+ caracteres). + +Via `curl`: + +```bash +# Logue para pegar o cookie de sessão +curl -i -c cookies.txt -X POST http://localhost:3001/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@example.com","password":""}' + +# Busca híbrida (vetor + full-text) +curl -b cookies.txt "http://localhost:3001/api/v1/search?q=arquitetura&projeto_id=" +``` + +## 5. Debug avançado com o workflow do n8n (opcional) + +O workflow `tJ7nYESUptHVmGCN-sinapse-rag-ingestão-chat-de-teste.json` +continua versionado em `n8n/workflows/` e aceita payload camelCase +(`{documentId, projectId, filename, storagePath}`). Para usá-lo: + +1. Ativar o workflow na UI do n8n (`http://localhost:5678`). +2. Confirmar que `AI_SERVICE_TOKEN` e `N8N_INGEST_TOKEN` estão expostos no + container do n8n (`docker exec sinapse-n8n env | grep -E "AI_SERVICE_TOKEN|N8N_INGEST_TOKEN"`). + O workflow lê de `$env.X` nos headers, **sem** precisar criar credencial + na UI. +3. Chamar o webhook com um documento já cadastrado no banco: + + ```bash + curl -X POST http://localhost:5678/webhook/sinapse-ingest \ + -H "Content-Type: application/json" \ + -d '{"documentId":"...","projectId":"...","filename":"x.md","storagePath":"/.md"}' + ``` + +Esse canal é redundante com o worker oficial — serve para visualizar o +fluxo passo-a-passo no editor do n8n quando houver suspeita de problema +no ai-service. + +## 6. Troubleshooting + +| Sintoma | Causa provável | Correção | +|---|---|---| +| `docker compose up` falha com `Configure AI_SERVICE_TOKEN in .env` | `.env` não existe ou não tem o token | `cp .env.example .env` | +| Mesma mensagem com `DOCUMENT_INGESTION_TOKEN` | Token ausente ou com menos de 32 chars | Gere com `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` | +| Documento fica preso em `pendente` | Worker não está rodando ou crashou na primeira tentativa | `docker logs sinapse-backend \| grep -i document`; reinicie o container | +| Status muda para `falha` com `processamento_erro: "A autenticação interna da ingestão não está configurada."` | `DOCUMENT_INGESTION_TOKEN` diferente entre backend e ai-service | Confirme o mesmo valor nos dois (`docker exec sinapse-backend env`, `docker exec sinapse-ai-service env`) | +| Status `falha` com `invalid embedding dimension` | Ollama rodando com modelo diferente de `bge-m3` | `ollama pull bge-m3`; checar `OLLAMA_EMBEDDING_MODEL` | +| Status `falha` com `503 service unavailable` | ai-service não está no ar ou Ollama caiu | `docker logs sinapse-ai-service` / `curl http://localhost:11434` | +| `/admin/ingestion` dá 403 | Logado como PO/dev, não admin | `npm run bootstrap:admin` (backend/) | +| UI "Reprocessar" responde mas documento não sai da fila | Worker reivindicou e falhou de novo | Olhe `processamento_erro` na próxima atualização; se for `503`, aguarde Ollama responder | +| Documento sempre falha com `401` do ai-service (logs do ai-service) | Bug já corrigido (commit `b1c7a60`): `HttpDocumentIngestionClient` não mandava `X-Service-Token`, só `X-Document-Ingestion-Token` | `git pull`; se persistir, confirme que está na branch atualizada | +| Erro de embedding some sozinho na próxima tentativa, ou Ollama trava/derruba conexão no meio da chamada | Duas instâncias de `ollama serve` rodando ao mesmo tempo (comum no Windows: o app de bandeja já sobe uma) | `Get-Process -Name "ollama*"`; mate todas e suba uma única com `OLLAMA_KEEP_ALIVE=24h ollama serve` | + +## 7. Avaliação da busca (S2-17) + +```bash +docker exec sinapse-ai-service python -m evaluation.run_search_suite \ + --dataset evaluation/datasets/search-ptbr-v2.json +``` + +Relatório sai em stdout com Top-K accuracy, taxa de falso-positivo e +latência média. Datasets em `ai-service/evaluation/datasets/`. diff --git a/ai-service/analyzer/config.py b/ai-service/analyzer/config.py index 6be0a0a..466438d 100644 --- a/ai-service/analyzer/config.py +++ b/ai-service/analyzer/config.py @@ -28,6 +28,11 @@ class AnalyzerSettings: ollama_max_retries: int = int(os.getenv("OLLAMA_MAX_RETRIES", "2")) ollama_retry_backoff_seconds: float = float(os.getenv("OLLAMA_RETRY_BACKOFF_SECONDS", "3")) max_synthesis_chars: int = int(os.getenv("MAX_SYNTHESIS_CHARS", "60000")) + max_active_runs: int = max(1, int(os.getenv("ANALYZER_MAX_ACTIVE_RUNS", "2"))) + max_queued_runs: int = max(0, int(os.getenv("ANALYZER_MAX_QUEUED_RUNS", "6"))) + workspace_quota_mb: int = max(1, int(os.getenv("ANALYZER_WORKSPACE_QUOTA_MB", "2048"))) + clone_timeout_seconds: int = max(1, int(os.getenv("ANALYZER_CLONE_TIMEOUT_SECONDS", "300"))) + clone_memory_mb: int = max(128, int(os.getenv("ANALYZER_CLONE_MEMORY_MB", "512"))) ollama_concurrency: int = max(1, int(os.getenv("OLLAMA_CONCURRENCY", "1"))) diff --git a/ai-service/analyzer/limited_git.py b/ai-service/analyzer/limited_git.py new file mode 100644 index 0000000..1b79e41 --- /dev/null +++ b/ai-service/analyzer/limited_git.py @@ -0,0 +1,11 @@ +"""Exec git with per-process bounds without unsafe preexec_fn in threaded workers.""" +import os +import resource +import sys + +if __name__ == "__main__": + disk_mb, memory_mb, cpu_seconds = map(int, sys.argv[1:4]) + resource.setrlimit(resource.RLIMIT_FSIZE, (disk_mb * 1024**2,) * 2) + resource.setrlimit(resource.RLIMIT_AS, (memory_mb * 1024**2,) * 2) + resource.setrlimit(resource.RLIMIT_CPU, (cpu_seconds,) * 2) + os.execvp("git", ["git", "-c", "core.hooksPath=/dev/null", *sys.argv[4:]]) diff --git a/ai-service/analyzer/pipeline.py b/ai-service/analyzer/pipeline.py index 475636d..981168e 100644 --- a/ai-service/analyzer/pipeline.py +++ b/ai-service/analyzer/pipeline.py @@ -3,6 +3,9 @@ import os import re import shutil +import signal +import sys +import tempfile import subprocess import threading import time @@ -21,13 +24,17 @@ project_synthesis_prompt, single_chunk_prompt, ) -from .scanner import scan_repository, select_analysis_files +from .scanner import scan_repository, select_analysis_files, read_repository_text class AnalysisError(RuntimeError): pass +class AnalysisBusy(AnalysisError): + pass + + STAGES = [ ("ollama", "Verificando Ollama"), ("clone", "Clonando repositório"), @@ -66,9 +73,23 @@ def __init__(self, settings: AnalyzerSettings): self.runs: dict[str, RunState] = {} self.lock = threading.Lock() self.checkpoint_lock = threading.Lock() + self.admission_lock = threading.RLock() + self.worker_slots = threading.BoundedSemaphore(settings.max_active_runs) self._restore_runs() - def start(self, url: str, profile: str = "quick") -> str: + def start(self, url: str, profile: str = "quick", run_id: str | None = None) -> str: + with self.admission_lock: + return self._start(url, profile, run_id) + + def _check_capacity(self): + with self.lock: + active = sum(s.status in {"queued", "running", "pausing", "cancelling"} for s in self.runs.values()) + if active >= self.settings.max_active_runs + self.settings.max_queued_runs: + raise AnalysisBusy("Fila de análises cheia. Tente novamente mais tarde.") + if self._directory_size(self.settings.workspace_dir) >= self.settings.workspace_quota_mb * 1024**2: + raise AnalysisBusy("Cota de armazenamento das análises atingida.") + + def _start(self, url: str, profile: str, run_id: str | None) -> str: normalized_url = self._normalize_github_url(url) if not normalized_url: raise AnalysisError( @@ -79,32 +100,74 @@ def start(self, url: str, profile: str = "quick") -> str: if profile not in {"quick", "balanced", "complete"}: raise AnalysisError("Perfil inválido. Use quick, balanced ou complete.") - run_id = uuid.uuid4().hex[:12] + run_id = run_id or uuid.uuid4().hex[:12] + if not re.fullmatch(r"[A-Za-z0-9-]{1,64}", run_id): + raise AnalysisError("Identificador de execução inválido.") + existing = self.runs.get(run_id) or self._load_persisted_run(run_id) + if existing: + if existing.url != normalized_url or existing.profile != profile: + raise AnalysisError("Identificador já usado para outra solicitação.") + return run_id + self._check_capacity() state = RunState(run_id=run_id, url=normalized_url, status="queued", profile=profile) with self.lock: self.runs[run_id] = state - self._persist_checkpoint(state) + try: + self._persist_checkpoint(state) + except Exception: + with self.lock: + self.runs.pop(run_id, None) + raise self._start_worker(state) return run_id def _start_worker(self, state: RunState) -> None: - thread = threading.Thread(target=self._run, args=(state.run_id,), daemon=True) + thread = threading.Thread(target=self._queued_run, args=(state.run_id,), daemon=True) state.worker_thread = thread thread.start() + def _queued_run(self, run_id: str): + acquired = False + try: + while not acquired: + self._check_control(run_id) + acquired = self.worker_slots.acquire(timeout=0.1) + self._check_control(run_id) + self._run(run_id) + except AnalysisPaused: + self._push(run_id, status="paused", message="Análise pausada na fila.") + except AnalysisCancelled: + self._push(run_id, status="cancelled", message="Análise cancelada na fila.") + finally: + if acquired: + self.worker_slots.release() + def pause(self, run_id: str) -> dict: state = self._get_run(run_id) - if state.status not in {"queued", "running"}: - raise AnalysisError("Só é possível pausar uma análise em andamento.") - self._push(run_id, status="pausing", message="Aguardando concluir a etapa atual para salvar o progresso…") - state.pause_event.set() + with self.lock: + if state.status not in {"queued", "running"}: + raise AnalysisError("Só é possível pausar uma análise em andamento.") + state.status = "pausing" + state.message = "Aguardando concluir a etapa atual para salvar o progresso…" + state.pause_event.set() + state.stats = self._snapshot(state) + self._persist_checkpoint(state) return self.status(run_id) def resume(self, run_id: str) -> dict: + with self.admission_lock: + return self._resume(run_id) + + def _resume(self, run_id: str) -> dict: state = self._get_run(run_id) + if state.status in {"queued", "running", "completed"}: + return self.status(run_id) has_progress = bool(state.completed_summaries or state.partial_chunk_summaries or state.project_summary) if state.status != "paused" and not (state.status == "failed" and has_progress): raise AnalysisError("A análise não está pausada ou não possui progresso recuperável.") + if state.worker_thread and state.worker_thread.is_alive(): + raise AnalysisError("Aguarde o encerramento da etapa atual antes de retomar.") + self._check_capacity() state.pause_event.clear() state.cancel_event.clear() self._push(run_id, status="queued", error="", message="Retomando do último ponto salvo…") @@ -113,14 +176,16 @@ def resume(self, run_id: str) -> dict: def cancel(self, run_id: str) -> dict: state = self._get_run(run_id) - if state.status not in {"queued", "running", "pausing", "paused"}: - raise AnalysisError("Só é possível cancelar uma análise não finalizada.") - was_paused = state.status == "paused" - state.pause_event.clear() - self._push(run_id, status="cancelling", message="Cancelamento solicitado. A chamada atual ao modelo será concluída.") - state.cancel_event.set() - if was_paused: - self._push(run_id, status="cancelled", message="Análise cancelada pelo usuário.", current_files=[]) + with self.admission_lock: + with self.lock: + if state.status not in {"cancelled", "cancelling", "completed", "failed"}: + was_paused = state.status == "paused" + state.pause_event.clear() + state.cancel_event.set() + state.status = "cancelled" if was_paused else "cancelling" + state.message = "Cancelamento solicitado." + state.stats = self._snapshot(state) + self._persist_checkpoint(state) return self.status(run_id) def _get_run(self, run_id: str) -> RunState: @@ -175,6 +240,8 @@ def _restore_runs(self) -> None: continue def _load_persisted_run(self, run_id: str) -> RunState | None: + if not re.fullmatch(r"[A-Za-z0-9-]{1,64}", run_id): + raise AnalysisError("Identificador de execução inválido.") checkpoint_path = self.settings.workspace_dir / "runs" / run_id / "checkpoint.json" if checkpoint_path.exists(): try: @@ -448,9 +515,14 @@ def _run(self, run_id: str): run_dir.mkdir(parents=True, exist_ok=True) self._check_control(run_id) - if not repo_dir.exists(): + clone_marker = run_dir / "clone.complete" + if not repo_dir.exists() or not clone_marker.exists(): + state.completed_summaries.clear() + state.partial_chunk_summaries.clear() + state.project_summary = "" self._push(run_id, stage="clone", message="Clonando repositório (raso, 1 commit)...") self._clone(state.url, repo_dir) + clone_marker.write_text("complete", encoding="utf-8") size_mb = self._directory_size(repo_dir) / (1024 * 1024) if size_mb > self.settings.max_repo_size_mb: @@ -639,15 +711,7 @@ def _process_file(self, run_id: str, repo_dir: Path, info) -> str: succeeded = False try: path = repo_dir / info.path - raw = path.read_bytes() - if raw.startswith((b"\x00\x00\xfe\xff", b"\xff\xfe\x00\x00")): - text = raw.decode("utf-32", errors="replace") - elif raw.startswith((b"\xff\xfe", b"\xfe\xff")): - text = raw.decode("utf-16", errors="replace") - elif raw.startswith(b"\xef\xbb\xbf"): - text = raw.decode("utf-8-sig", errors="replace") - else: - text = raw.decode("utf-8", errors="replace") + text = read_repository_text(repo_dir.resolve(), path.absolute(), self.settings.max_file_size_kb * 1024) chunks = self._chunks(text, self.settings.max_chunk_chars) symbols_json = json.dumps(info.symbols, ensure_ascii=False) state = self.runs[run_id] @@ -769,32 +833,36 @@ def _clone(self, url: str, destination: Path): destination.parent.mkdir(parents=True, exist_ok=True) - try: - result = subprocess.run( - [ - "git", "clone", - "--depth", "1", - "--single-branch", - "--no-tags", - url, - str(destination), - ], - capture_output=True, - text=True, - timeout=300, - ) - except FileNotFoundError as exc: - raise AnalysisError( - "Git não foi encontrado no PATH. Instale o Git e tente novamente." - ) from exc - except subprocess.TimeoutExpired as exc: - raise AnalysisError("O clone excedeu o tempo limite.") from exc - - if result.returncode != 0: - raise AnalysisError( - "Falha ao clonar o repositório:\n" + - (result.stderr.strip() or result.stdout.strip()) - ) + run_id = destination.parent.name + command = [sys.executable, "-m", "analyzer.limited_git", str(self.settings.max_repo_size_mb), + str(self.settings.clone_memory_mb), str(self.settings.clone_timeout_seconds), + "clone", "--depth", "1", "--single-branch", "--no-tags", url, str(destination)] + started = time.monotonic() + with tempfile.TemporaryFile() as output: + process = subprocess.Popen(command, stdout=output, stderr=output, start_new_session=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_LFS_SKIP_SMUDGE": "1"}) + try: + while process.poll() is None: + if run_id in self.runs: + self._check_control(run_id) + if time.monotonic() - started > self.settings.clone_timeout_seconds: + raise AnalysisError("O clone excedeu o tempo limite.") + if self._directory_size(destination) > self.settings.max_repo_size_mb * 1024**2: + raise AnalysisError("O clone excedeu a cota de disco por repositório.") + if self._directory_size(self.settings.workspace_dir) > self.settings.workspace_quota_mb * 1024**2: + raise AnalysisError("O clone excedeu a cota global de disco.") + time.sleep(0.1) + if process.returncode != 0: + raise AnalysisError("Falha ao clonar o repositório ou limite de recursos atingido.") + if self._directory_size(destination) > self.settings.max_repo_size_mb * 1024**2: + raise AnalysisError("O clone excedeu a cota de disco por repositório.") + except BaseException: + if process.poll() is None: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + if destination.exists(): + shutil.rmtree(destination) + raise @staticmethod def _directory_size(path: Path) -> int: diff --git a/ai-service/analyzer/scanner.py b/ai-service/analyzer/scanner.py index d7a70ba..b8ca7b7 100644 --- a/ai-service/analyzer/scanner.py +++ b/ai-service/analyzer/scanner.py @@ -1,5 +1,7 @@ import ast import os +import stat +from contextlib import contextmanager from pathlib import Path from collections import Counter @@ -104,16 +106,54 @@ def detect_language(path: Path) -> str: return LANGUAGES.get(path.suffix.lower(), "Unknown") -def is_probably_binary(path: Path) -> bool: +@contextmanager +def open_repository_file(root: Path, path: Path): + """Walk with no-follow descriptors: reject symlinks, special files and escapes.""" + root = root.resolve(strict=True) + relative = path.absolute().relative_to(root) + if not relative.parts or any(part in {"..", "."} for part in relative.parts): + raise ValueError("Invalid repository path") + # Analyzer runs on Linux; descriptor-relative opens also prevent replacement races. + descriptors = [] + try: + current = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + descriptors.append(current) + for part in relative.parts[:-1]: + current = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=current) + descriptors.append(current) + fd = os.open(relative.parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=current) + descriptors.append(fd) + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise ValueError("Not a regular repository file") + with os.fdopen(os.dup(fd), "rb") as handle: + yield handle + finally: + for fd in reversed(descriptors): + os.close(fd) + + +def read_repository_text(root: Path, path: Path, max_bytes: int) -> str: + with open_repository_file(root, path) as handle: + data = handle.read(max_bytes + 1) + if len(data) > max_bytes: + raise ValueError("Repository file exceeds size limit") + if data.startswith((b"\x00\x00\xfe\xff", b"\xff\xfe\x00\x00")): + return data.decode("utf-32", errors="replace") + if data.startswith((b"\xff\xfe", b"\xfe\xff")): + return data.decode("utf-16", errors="replace") + return data.decode("utf-8-sig", errors="replace") + + +def is_probably_binary(path: Path, root: Path | None = None) -> bool: if path.suffix.lower() in BINARY_EXTENSIONS: return True try: - with open(path, "rb") as handle: + with open_repository_file(root or path.absolute().parent, path) as handle: data = handle.read(4096) if data.startswith((b"\xff\xfe", b"\xfe\xff", b"\x00\x00\xfe\xff", b"\xff\xfe\x00\x00")): return False return b"\x00" in data - except OSError: + except (OSError, ValueError): return True @@ -149,23 +189,28 @@ def python_symbols(text: str) -> dict: def scan_repository(repo_dir: Path, max_file_size: int, max_files: int): + repo_dir = repo_dir.resolve(strict=True) files: list[FileInfo] = [] ignored: list[dict] = [] for current, dirs, filenames in os.walk(repo_dir): - dirs[:] = [d for d in dirs if d not in IGNORED_DIRS] + dirs[:] = [d for d in dirs if d not in IGNORED_DIRS and not (Path(current) / d).is_symlink()] for filename in filenames: path = Path(current) / filename relative = path.relative_to(repo_dir).as_posix() try: - size = path.stat().st_size - except OSError: + metadata = path.lstat() + if not stat.S_ISREG(metadata.st_mode) or not path.resolve(strict=True).is_relative_to(repo_dir): + ignored.append({"path": relative, "reason": "unsafe_file"}) + continue + size = metadata.st_size + except (OSError, ValueError): ignored.append({"path": relative, "reason": "stat_error"}) continue - if is_probably_binary(path): + if is_probably_binary(path, repo_dir): ignored.append({"path": relative, "reason": "binary"}) continue @@ -192,9 +237,9 @@ def scan_repository(repo_dir: Path, max_file_size: int, max_files: int): if language == "Python": try: - text = path.read_text(encoding="utf-8", errors="replace") + text = read_repository_text(repo_dir, path, max_file_size) info.symbols = python_symbols(text) - except OSError: + except (OSError, ValueError): pass files.append(info) diff --git a/ai-service/config.py b/ai-service/config.py index 226168f..e124cd3 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 @@ -7,8 +8,11 @@ class Settings(BaseSettings): + AI_SERVICE_TOKEN: str = "" 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 +28,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/document_text.py b/ai-service/document_text.py new file mode 100644 index 0000000..1fc9414 --- /dev/null +++ b/ai-service/document_text.py @@ -0,0 +1,38 @@ +"""Extract supported uploads without filesystem paths or database access.""" +import base64 +import io +import zipfile +import xml.etree.ElementTree as ET +from pathlib import PurePath + +MAX_BYTES = 100 * 1024 * 1024 + + +def extract_document_text(file_name: str, content_base64: str) -> str: + if len(content_base64) > (MAX_BYTES * 4 // 3 + 4): + raise ValueError("Arquivo muito grande") + content = base64.b64decode(content_base64, validate=True) + if len(content) > MAX_BYTES: + raise ValueError("Arquivo muito grande") + extension = PurePath(file_name).suffix.lower() + if extension in (".txt", ".md"): + text = content.decode("utf-8-sig") + elif extension == ".docx": + with zipfile.ZipFile(io.BytesIO(content)) as archive: + info = archive.getinfo("word/document.xml") + if info.file_size > MAX_BYTES: + raise ValueError("Documento descompactado muito grande") + root = ET.fromstring(archive.read(info)) + namespace = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" + text = "\n".join("".join(node.itertext()) for node in root.iter(namespace + "p")) + elif extension == ".pdf": + from pypdf import PdfReader + reader = PdfReader(io.BytesIO(content)) + if reader.is_encrypted: + raise ValueError("PDF protegido não é suportado") + text = "\n".join(page.extract_text() or "" for page in reader.pages) + else: + raise ValueError("Formato não suportado") + if not text.strip() or len(text) > MAX_BYTES: + raise ValueError("Documento sem texto extraível ou acima do limite") + return text 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..edf43bb 100644 --- a/ai-service/main.py +++ b/ai-service/main.py @@ -1,14 +1,30 @@ +import hmac +import math +from fastapi import Request +from fastapi.responses import JSONResponse +from document_text import extract_document_text 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 from services.chunker import chunk_document_text, create_structured_chunk from analyzer import Analyzer, AnalysisError, AnalyzerSettings +from analyzer.pipeline import AnalysisBusy analyzer = Analyzer(AnalyzerSettings()) @@ -26,6 +42,18 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +@app.middleware("http") +async def authenticate_service(request: Request, call_next): + if request.url.path != "/health": + expected = settings.AI_SERVICE_TOKEN + supplied = request.headers.get("x-service-token", "") + if not expected: + return JSONResponse(status_code=503, content={"detail": "Autenticação interna não configurada"}) + if not hmac.compare_digest(supplied.encode(), expected.encode()): + return JSONResponse(status_code=401, content={"detail": "Autenticação interna necessária"}) + return await call_next(request) + + app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -53,6 +81,43 @@ class EmbeddingRequest(BaseModel): model: str | None = Field(None, description="Modelo de embedding (padrão do settings)") +class ChunkRequest(BaseModel): + document_id: str = Field(..., description="ID do documento no backend") + project_id: str | None = Field(None, description="ID do projeto") + filename: str | None = Field(None, description="Nome original do arquivo") + text: str = Field(..., description="Texto extraído do documento, já consolidado") + + +class EmbedBatchRequest(BaseModel): + model: str | None = Field(None, description="Modelo de embedding (padrão bge-m3)") + texts: list[str] = Field(..., description="Lote de textos para gerar embeddings") + + +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)") @@ -86,28 +151,37 @@ async def ingest_document(req: IngestDocumentRequest): """ chunks = chunk_document_text(req.text_content) - # Process each chunk with embedding + if not req.project_id or not chunks: + raise HTTPException(status_code=422, detail="Projeto e texto são obrigatórios") processed = [] for idx, chunk in enumerate(chunks): try: vector = await ollama_client.get_embedding(chunk) - vector_dim = len(vector) + if len(vector) != 1024: + raise ValueError("Dimensão de vetor inválida") except Exception: - vector_dim = 0 - - processed.append({ - "chunk_index": idx, - "text": chunk, - "vector_dimension": vector_dim, - "project_id": req.project_id, - }) - - return { - "document_id": req.document_id, - "total_chunks": len(chunks), - "status": "chunked_and_indexed", - "chunks": processed, - } + raise HTTPException(status_code=502, detail="Não foi possível gerar embeddings") + processed.append({"chunk_index": idx, "text": chunk, "embedding": vector, + "vector_dimension": len(vector), "project_id": req.project_id}) + return {"document_id": req.document_id, "project_id": req.project_id, + "total_chunks": len(chunks), "status": "prepared", "chunks": processed} + + +class IngestFileRequest(BaseModel): + document_id: str + project_id: str + file_name: str + content_base64: str + + +@app.post("/ingest/file") +async def ingest_file(req: IngestFileRequest): + try: + text = extract_document_text(req.file_name, req.content_base64) + except Exception: + raise HTTPException(status_code=422, detail="Não foi possível extrair texto do documento") + return await ingest_document(IngestDocumentRequest(document_id=req.document_id, + project_id=req.project_id, text_content=text)) @app.post("/ingest/entity", status_code=status.HTTP_200_OK) @@ -119,15 +193,18 @@ async def ingest_structured_entity(req: IngestEntityRequest): chunk = create_structured_chunk(req.entity_type, req.data) try: vector = await ollama_client.get_embedding(chunk["content"]) + if len(vector) != 1024 or not all(math.isfinite(v) for v in vector) or not any(vector): + raise ValueError("Embedding inválido") vector_dim = len(vector) except Exception: - vector_dim = 0 + raise HTTPException(status_code=502, detail="Não foi possível gerar embeddings") return { "entity_type": req.entity_type, "content": chunk["content"], "metadata": chunk["metadata"], "vector_dimension": vector_dim, + "embedding": vector, } @@ -145,6 +222,130 @@ async def generate_embedding(req: EmbeddingRequest): ) +@app.post("/chunk", status_code=status.HTTP_200_OK) +async def chunk_text(req: ChunkRequest): + """ + Divide o texto em chunks (sem calcular embeddings). Shape pensado para o + pipeline do n8n em modo debug: Chunking -> Embeddings -> Persistir chunks (backend). + """ + pieces = chunk_document_text(req.text) + chunks = [ + { + "index": idx, + "content": piece, + "metadata": { + "document_id": req.document_id, + "project_id": req.project_id, + "filename": req.filename, + }, + } + for idx, piece in enumerate(pieces) + ] + return { + "document_id": req.document_id, + "project_id": req.project_id, + "total_chunks": len(chunks), + "chunks": chunks, + } + + +@app.post("/embed", status_code=status.HTTP_200_OK) +async def embed_batch(req: EmbedBatchRequest): + """ + Gera embeddings em lote. Ollama processa um por vez; o loop aqui mantém a + ordem para casar vetor com chunk no caller. + """ + if not req.texts: + return {"model": req.model or settings.OLLAMA_EMBEDDING_MODEL, "dimension": 0, "embeddings": []} + model = req.model or settings.OLLAMA_EMBEDDING_MODEL + try: + vectors = [await ollama_client.get_embedding(text, model=model) for text in req.texts] + except Exception as e: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Falha ao comunicar com Ollama: {e}", + ) + dimension = len(vectors[0]) if vectors and vectors[0] else 0 + return {"model": model, "dimension": dimension, "embeddings": vectors} + + +@app.post("/documents/process") +async def process_document(req: ProcessDocumentRequest, internal_token: str | None = Header(None, alias="X-Document-Ingestion-Token")): + """Fluxo oficial da S2-01: 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): """ @@ -155,9 +356,13 @@ async def query_rag(req: RagQueryRequest): "Você é o assistente inteligente do Sinapse (memória da fábrica de software PRO4TECH). " "Suas respostas devem ser estritamente baseadas no contexto fornecido. " "Se a evidência não estiver no contexto, responda honestamente que a informação não foi encontrada no acervo. " - "Sempre cite a fonte (ID do projeto, requisito ou documento) ao justificar uma resposta." + "Cite os trechos usados com seus IDs exatamente entre colchetes, como [ID], conforme o contexto. Nunca invente IDs." ) + if not req.project_id: + raise HTTPException(status_code=422, detail="Projeto obrigatório") + if not req.context_chunks: + return {"response": "Informação não encontrada no acervo do projeto.", "project_id": req.project_id} context_str = "\n\n---\n\n".join(req.context_chunks) if req.context_chunks else "Nenhum contexto recuperado." user_prompt = f"Contexto do Acervo:\n{context_str}\n\nPergunta do Product Owner:\n{req.query}" @@ -182,6 +387,7 @@ async def query_rag(req: RagQueryRequest): class AnalyzeRequest(BaseModel): url: str = Field(..., description="URL pública do repositório GitHub") + run_id: str | None = Field(None, pattern=r"^[A-Za-z0-9-]{1,64}$") profile: Literal["quick", "balanced", "complete"] = Field( "quick", description="Quantidade e prioridade dos arquivos enviados ao modelo local" ) @@ -191,8 +397,10 @@ class AnalyzeRequest(BaseModel): def analyze_repository(req: AnalyzeRequest): """Inicia a análise assíncrona de um repositório GitHub.""" try: - run_id = analyzer.start(str(req.url), req.profile) + run_id = analyzer.start(str(req.url), req.profile, req.run_id) return {"run_id": run_id, "status": "started"} + except AnalysisBusy as exc: + raise HTTPException(status_code=429, detail=str(exc)) except AnalysisError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) except Exception as exc: @@ -211,6 +419,8 @@ def pause_analysis(run_id: str): def resume_analysis(run_id: str): try: return analyzer.resume(run_id) + except AnalysisBusy as exc: + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(exc)) except AnalysisError as exc: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) diff --git a/ai-service/requirements.txt b/ai-service/requirements.txt index 17c68bb..64af781 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,<7 +python-docx>=1.1.2 diff --git a/ai-service/services/chunker.py b/ai-service/services/chunker.py index 2b4432b..d7c17e2 100644 --- a/ai-service/services/chunker.py +++ b/ai-service/services/chunker.py @@ -1,5 +1,4 @@ from typing import Any -import re def chunk_document_text(text: str, chunk_size: int = 1000, overlap: int = 150) -> list[str]: @@ -8,45 +7,33 @@ def chunk_document_text(text: str, chunk_size: int = 1000, overlap: int = 150) - with an overlap of ~150 chars, breaking primarily on paragraph or sentence boundaries. Complies with Sinapse PRD Section 10.3. """ + if chunk_size <= 0 or not 0 <= overlap < chunk_size: + raise ValueError("Require chunk_size > 0 and 0 <= overlap < chunk_size") if not text or not text.strip(): return [] - # Normalize newlines - text = text.replace("\r\n", "\n") - paragraphs = re.split(r"\n\s*\n", text) - + # Fixed-offset slicing (not paragraph reflow): each chunk is a contiguous + # slice of the original text, overlapping the previous one by exactly + # `overlap` chars. This guarantees lossless reconstruction + # (chunks[0] + "".join(c[overlap:] for c in chunks[1:]) == text), which + # the ingestion pipeline's safety tests rely on. Paragraph boundaries are + # only a soft preference for where to cut, never reflowed. + text = text.replace("\r\n", "\n").strip() chunks: list[str] = [] - current_chunk: list[str] = [] - current_len = 0 - - 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]) - - if current_chunk: - chunks.append("\n\n".join(current_chunk)) - - return [c.strip() for c in chunks if c.strip()] + start = 0 + while start < len(text): + end = min(start + chunk_size, len(text)) + if end < len(text): + boundary = text.rfind("\n\n", start + max(overlap + 1, chunk_size // 2), end) + if boundary >= 0: + end = boundary + 2 + chunk = text[start:end] + if chunk.strip(): + chunks.append(chunk) + if end == len(text): + break + start = end - overlap + return chunks def create_structured_chunk(entity_type: str, data: dict[str, Any]) -> dict[str, Any]: diff --git a/ai-service/tests/test_analyzer.py b/ai-service/tests/test_analyzer.py index 2743c9b..e0cd4a8 100644 --- a/ai-service/tests/test_analyzer.py +++ b/ai-service/tests/test_analyzer.py @@ -11,8 +11,11 @@ class TestAnalyzer(unittest.TestCase): def setUp(self): - self.settings = AnalyzerSettings(workspace_dir=Path("test_workspace")) + workspace = TemporaryDirectory() + self.addCleanup(workspace.cleanup) + self.settings = AnalyzerSettings(workspace_dir=Path(workspace.name)) self.analyzer = Analyzer(self.settings) + self.addCleanup(self.analyzer.client.close) def test_normalize_github_url(self): # Valid URLs 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_internal_security.py b/ai-service/tests/test_internal_security.py new file mode 100644 index 0000000..eb813bd --- /dev/null +++ b/ai-service/tests/test_internal_security.py @@ -0,0 +1,35 @@ +import unittest +from unittest.mock import AsyncMock, patch +import httpx +from fastapi import HTTPException +from main import app, settings, ingest_structured_entity, IngestEntityRequest + +class InternalSecurityTests(unittest.IsolatedAsyncioTestCase): + async def test_requires_token_for_analysis_and_ingestion(self): + with patch.object(settings, "AI_SERVICE_TOKEN", "test-secret"): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app),base_url="http://test") as client: + for path in ["/api/runs", "/api/runs/example/report", "/docs"]: + self.assertEqual((await client.get(path)).status_code,401) + for path in ["/api/analyze", "/ingest/file", "/ingest/entity", "/rag/query"]: + self.assertEqual((await client.post(path,json={})).status_code,401) + self.assertEqual((await client.get("/api/runs",headers={"X-Service-Token":"wrong"})).status_code,401) + with patch("main.analyzer.list_runs",return_value=[]): + self.assertEqual((await client.get("/api/runs",headers={"X-Service-Token":"test-secret"})).status_code,200) + async def test_missing_configuration_fails_closed(self): + with patch.object(settings,"AI_SERVICE_TOKEN",""): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app),base_url="http://test") as client: + self.assertEqual((await client.get("/api/runs")).status_code,503) + async def test_entity_embedding_failure_and_invalid_vector(self): + for vector in [[],[0.0]*1024,[float("nan")]*1024]: + with patch("main.ollama_client.get_embedding",new=AsyncMock(return_value=vector)): + with self.assertRaises(HTTPException) as error: + await ingest_structured_entity(IngestEntityRequest(entity_type="feature",data={"titulo":"Teste"})) + self.assertEqual(error.exception.status_code,502) + with patch("main.ollama_client.get_embedding",new=AsyncMock(side_effect=RuntimeError("secret"))): + with self.assertRaises(HTTPException) as error: + await ingest_structured_entity(IngestEntityRequest(entity_type="feature",data={"titulo":"Teste"})) + self.assertNotIn("secret",error.exception.detail) + async def test_entity_returns_real_vector(self): + with patch("main.ollama_client.get_embedding",new=AsyncMock(return_value=[0.1]*1024)): + result=await ingest_structured_entity(IngestEntityRequest(entity_type="feature",data={"titulo":"Teste"})) + self.assertEqual(len(result["embedding"]),1024) diff --git a/ai-service/tests/test_safety_limits.py b/ai-service/tests/test_safety_limits.py new file mode 100644 index 0000000..18e2a54 --- /dev/null +++ b/ai-service/tests/test_safety_limits.py @@ -0,0 +1,145 @@ +import os +import threading +import time +import subprocess +import sys +import json +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from analyzer.config import AnalyzerSettings +from analyzer.pipeline import Analyzer, AnalysisBusy, AnalysisError +from analyzer.scanner import scan_repository, read_repository_text +from services.chunker import chunk_document_text + + +class SafetyTests(unittest.TestCase): + def test_git_subprocess_enforces_memory_file_and_cpu_limits(self): + with TemporaryDirectory() as folder: + fake_git = Path(folder) / 'git' + fake_git.write_text(f'#!{sys.executable}\n' + '''import resource, json, os +memory_limited = False +try: + data = bytearray(256 * 1024**2) +except MemoryError: + memory_limited = True +file_limited = False +try: + with open(os.environ['LIMIT_TEST_FILE'], 'wb') as f: + f.write(b'x' * (2 * 1024**2)) +except OSError: + file_limited = True +print(json.dumps([memory_limited, file_limited, resource.getrlimit(resource.RLIMIT_CPU)[0]])) +''') + fake_git.chmod(0o755) + result = subprocess.run([sys.executable, '-m', 'analyzer.limited_git', '1', '128', '2'], + env={**os.environ, 'PATH': folder + os.pathsep + os.environ['PATH'], 'LIMIT_TEST_FILE': str(Path(folder) / 'data')}, + capture_output=True, text=True, timeout=5, check=True) + self.assertEqual(json.loads(result.stdout), [True, True, 2]) + self.assertLessEqual((Path(folder) / 'data').stat().st_size, 1024**2) + + def test_links_chains_directories_and_fifo_never_enter_inventory(self): + with TemporaryDirectory() as folder: + root = Path(folder) / 'repo' + root.mkdir() + (Path(folder) / 'private.txt').write_text('private') + (root / 'normal.py').write_text('print(1)') + (root / 'link.txt').symlink_to(Path(folder) / 'private.txt') + (root / 'chain.txt').symlink_to(root / 'link.txt') + (root / 'directory').symlink_to(Path(folder), target_is_directory=True) + os.mkfifo(root / 'fifo') + inventory = scan_repository(root, 1000, 100) + self.assertEqual([f.path for f in inventory['files']], ['normal.py']) + for path in ['link.txt', 'chain.txt', 'fifo', 'directory/private.txt', '../private.txt']: + with self.assertRaises((ValueError, OSError)): + read_repository_text(root, root / path, 1000) + (root / 'normal.py').unlink() + (root / 'normal.py').symlink_to(Path(folder) / 'private.txt') + with self.assertRaises(OSError): + read_repository_text(root, root / inventory['files'][0].path, 1000) + + def test_text_encodings_and_file_size(self): + with TemporaryDirectory() as folder: + root = Path(folder) + for encoding in ['utf-8', 'utf-8-sig', 'utf-16', 'utf-32']: + path = root / 'text.txt' + path.write_text('ação 😊', encoding=encoding) + self.assertEqual(read_repository_text(root, path, 100), 'ação 😊') + with self.assertRaises(ValueError): + read_repository_text(root, path, 2) + + def test_chunk_bounds_overlap_and_complete_reconstruction(self): + for text in ['x'*60000, 'á😊漢'*5000, 'a'*1000, 'abc\n\ndef'*1000, 'x'*1001]: + for overlap in [0, 150]: + chunks = chunk_document_text(text, 1000, overlap) + self.assertTrue(all(0 < len(c) <= 1000 for c in chunks)) + restored = chunks[0] + ''.join(c[overlap:] for c in chunks[1:]) + self.assertEqual(restored, text) + for size, overlap in [(0,0),(10,10),(10,-1)]: + with self.assertRaises(ValueError): + chunk_document_text('text', size, overlap) + + def test_global_concurrency_queue_and_idempotent_start(self): + with TemporaryDirectory() as folder: + analyzer = Analyzer(AnalyzerSettings(workspace_dir=Path(folder), max_active_runs=1, max_queued_runs=1)) + self.addCleanup(analyzer.client.close) + release = threading.Event() + started = threading.Event() + calls = [] + def run(run_id): + calls.append(run_id) + started.set() + release.wait(3) + analyzer._push(run_id, status='completed') + analyzer._run = run + try: + analyzer.start('https://github.com/acme/repo', run_id='first') + self.assertTrue(started.wait(1)) + analyzer.start('https://github.com/acme/repo', run_id='second') + self.assertEqual(analyzer.start('https://github.com/acme/repo', run_id='first'), 'first') + with self.assertRaises(AnalysisBusy): + analyzer.start('https://github.com/acme/repo', run_id='third') + self.assertEqual(calls, ['first']) + analyzer.cancel('second') + analyzer.runs['second'].worker_thread.join(2) + self.assertEqual(analyzer.status('second')['status'], 'cancelled') + self.assertEqual(calls, ['first']) + finally: + release.set() + analyzer.runs['first'].worker_thread.join(3) + + def test_clone_disk_limit_kills_process_and_removes_partial_clone(self): + with TemporaryDirectory() as folder: + analyzer = Analyzer(AnalyzerSettings(workspace_dir=Path(folder), max_repo_size_mb=1)) + self.addCleanup(analyzer.client.close) + destination = Path(folder) / 'runs' / 'big' / 'repo' + class Process: + pid = 12345 + def poll(self): return None + def wait(self): return 0 + def launch(*args, **kwargs): + destination.mkdir(parents=True) + (destination / 'large').write_bytes(b'x' * (1024**2 + 1)) + return Process() + with patch('analyzer.pipeline.subprocess.Popen', side_effect=launch), patch('analyzer.pipeline.os.killpg') as kill: + with self.assertRaisesRegex(AnalysisError, 'cota de disco'): + analyzer._clone('https://github.com/acme/big', destination) + kill.assert_called_once() + self.assertFalse(destination.exists()) + + def test_checkpoint_without_completed_clone_reclones_before_scan(self): + with TemporaryDirectory() as folder: + from analyzer.models import RunState + analyzer = Analyzer(AnalyzerSettings(workspace_dir=Path(folder))) + self.addCleanup(analyzer.client.close) + state = RunState(run_id='interrupted', url='https://github.com/acme/repo', status='paused', stage='clone') + analyzer.runs[state.run_id] = state + repo = Path(folder) / 'runs' / state.run_id / 'repo' + repo.mkdir(parents=True) + (repo / 'incomplete.txt').write_text('incomplete') + with patch.object(analyzer.client, 'check'), patch.object(analyzer, '_clone', side_effect=AnalysisError('reclone')) as clone: + analyzer._run(state.run_id) + clone.assert_called_once() + self.assertEqual(state.status, 'failed') 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/README.md b/backend/README.md new file mode 100644 index 0000000..25842c7 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,69 @@ +# Organização do backend + +A aplicação mantém os contratos HTTP e os aliases existentes. O código é organizado por domínio em src/modules. + +- app.ts configura Express, middlewares, rotas e tratamento de erros. Importá-lo não inicia o servidor ou o worker. +- index.ts é o ponto de entrada do processo: inicia o servidor e o worker de documentos. +- *.routes.ts registra endpoints e permissões. +- *.controller.ts adapta requisições e respostas HTTP e encaminha erros ao middleware global. +- *.service.ts contém validações e coordena operações do domínio. +- *.repository.ts concentra SQL e acesso ao PostgreSQL; aceita a dependência de banco pelo construtor. +- *.types.ts declara os contratos de dados do módulo quando necessário. + +Administração, desenvolvedores e busca seguem essas camadas. O catálogo de tecnologias faz apenas uma leitura: seu controller usa o repository diretamente, evitando um service que somente repassaria a chamada. + +A busca preserva os parâmetros projeto_id e projectId e o acervo global quando nenhum projeto é informado, conforme o contrato existente. Valores repetidos/objetos são rejeitados com HTTP 400, em vez de provocar erro interno ao chamar trim. Filtros SQL continuam parametrizados. + +As estatísticas administrativas usam uma única consulta. As três leituras independentes do painel de desenvolvedores executam em paralelo. A carga demonstrativa usa POST /api/v1/admin/demo-seed com projeto_id obrigatório. O alias /ingest-seed continua disponível com o mesmo corpo obrigatório. Não existe escolha implícita do primeiro projeto, geração de vetores ou reindexação nessa ação. Identificadores estáveis por projeto e versão impedem duplicação; a carga é auditada na mesma transação e rejeita projetos arquivados. Dados demonstrativos antigos não são removidos automaticamente. chunksIndexados conta somente trechos com embedding não nulo. + +GET /api/v1/admin/ingestion expõe o snapshot do pipeline de documentos (contagens por status, ativos, falhas) para a tela `/admin/ingestion` do frontend. `npm run bootstrap:admin` cria ou promove o admin local de teste sem precisar de SQL manual. Ver [../IMPLEMENTACAO_PIPELINE_INGESTAO.md](../IMPLEMENTACAO_PIPELINE_INGESTAO.md) para a arquitetura completa do pipeline e [../TESTE_INGESTAO.md](../TESTE_INGESTAO.md) para rodar e validar, com ou sem Docker. + +## Validação + +Execute npm test e npm run typecheck nesta pasta. architecture.routes.test.ts cobre permissões administrativas, carga sem projeto, aliases e validação da busca, parâmetros SQL e propagação segura de erros. O teste de tecnologias e a suíte OpenAPI verificam os contratos existentes. + +Os testes PostgreSQL usam bancos descartáveis identificados como teste. Execute a suíte com ARCHIVE_TEST_DATABASE_URL, BACKLOG_TREE_TEST_DATABASE_URL e SEED_TEST_DATABASE_URL configuradas para o banco de teste para incluir as verificações de integração. + +## Limites das transações + +`database/transaction.ts` centraliza commit, rollback e liberação da conexão nos repositórios de projetos, épicos, features, PBIs, critérios e administração. Uma falha no rollback descarta a conexão e preserva o erro original. A leitura da árvore mantém uma transação somente de leitura com snapshot REPEATABLE READ. + +`ProjectsRepository` preserva o contrato público e delega para `projects.queries.ts` (consultas), `projects.commands.ts` (cadastro/edição), `projects.backlog.ts` (árvore) e `projects.archive.ts` (impacto/arquivamento). Consultas de resposta em edição e arquivamento usam a conexão da transação, sem adquirir uma segunda conexão do pool. + +Consultas de épicos, features e PBIs aceitam um executor opcional. Dentro de transações, use sempre o mesmo PoolClient, inclusive para ler a resposta antes do COMMIT. Isso permite executar atualizações e conclusões com um pool de uma única conexão. + +A conclusão de PBI recebe uma validação obrigatória executada depois dos locks da hierarquia e antes de alterar o status. QualityService propaga o client às consultas do PBI, critérios e configuração; a configuração usa FOR SHARE até o commit. A ordem de locks da hierarquia é a mesma usada pelas escritas de critérios. Uma conclusão repetida não gera nova auditoria. + +priority-fixes.db.test.ts verifica pool unitário, rollback por falha de auditoria, exclusão concorrente de critério, bloqueio da configuração e cargas simultâneas sem duplicação. O workflow de CI executa esse arquivo junto aos testes PostgreSQL. + +As escritas na hierarquia adquirem `FOR UPDATE` na linha do projeto antes dos locks dos descendentes. Escritas de critérios, documentos, decisões e arquivamentos usam a mesma ordem. Projetos diferentes não compartilham esse bloqueio. A transação da leitura da árvore continua usando snapshot REPEATABLE READ. + +## Chat e ingestão + +Novas conversas exigem projeto. O projeto é imutável durante a conversa. Admins e POs podem consultar projetos; desenvolvedores precisam de alocação ativa, considerando início e fim. O endpoint `/chat/projects` lista as opções permitidas. Conversas antigas sem projeto são preservadas no banco, mas não participam de consultas globais nem aparecem na lista ativa do chat. + +Perguntas são persistidas como `pending`, com atualização da conversa na mesma transação. A resposta e a transição para `completed` também são atômicas. Falhas ficam como `failed`; tentativas interrompidas há mais de dois minutos aparecem como falhas no histórico. Somente uma pergunta fica em processamento por conversa. Chamadas de rede não mantêm uma transação aberta. + +O cliente Python recebe `query`, `project_id` e trechos selecionados pelo backend somente no projeto autorizado. As fontes retornadas ao usuário vêm desses trechos, não de identificadores inventados pelo modelo. Sem evidências, o modelo não é chamado. A recuperação atual do chat usa busca textual. + +A migração `014_chat_and_ingestion.sql` adiciona estados de mensagens e leases de processamento de documentos. A fila de ingestão e seu worker ficam em `documents.ingestion.ts`. Consulte [a configuração dos workflows](../n8n/README.md). Os testes de ingestão cobrem falha, retentativa, lease concorrente, dimensão dos vetores, idempotência e remoção durante processamento. + +### Acesso por projeto e comunicação interna + +PO/admin podem consultar todos os projetos. Desenvolvedores precisam de alocação ativa; coleções são filtradas antes da paginação e detalhes sem acesso retornam 404. A regra é compartilhada por projetos, backlog, documentos, decisões, histórico, busca e chat, incluindo aliases `/api`. + +Defina `AI_SERVICE_TOKEN` com um segredo aleatório em `.env`. O Compose exige a configuração e não publica a porta do Python. O workflow n8n deve ser atualizado para encaminhar `X-Service-Token`. Não versione esse segredo. + +O chat somente apresenta IDs explicitamente citados que pertencem ao contexto recuperado. Respostas sem citações válidas usam o fallback textual; essa validação não comprova semanticamente cada afirmação. + +## Fila de análise de repositórios + +A migration 016 registra a solicitação antes de chamar o Python. O worker usa lease e um run_id estável; perda de resposta HTTP repete a solicitação sem iniciar outro trabalho. Clientes podem enviar Idempotency-Key (UUID) e devem reutilizá-la no retry do mesmo formulário. Retomadas também são enfileiradas sob o bloqueio do projeto. Nenhuma chamada HTTP mantém transação aberta. + +Há no máximo uma análise não finalizada por projeto e três por usuário. O arquivamento pede que a análise seja concluída ou cancelada primeiro; solicitação e arquivamento usam o mesmo lock. Cancelar antes do primeiro despacho dispensa o Python. Depois de um despacho incerto, o cancelamento é durável e será entregue quando o serviço voltar. + +Conclusão e disponibilidade do relatório são separadas: análises concluídas sem relatório continuam sendo sincronizadas pelo worker e pela tela. Execução inexistente no Python torna-se falha explícita. O volume repo_analysis_data preserva checkpoints; um clone sem marcador de conclusão é refeito, descartando resumos incompatíveis. + +O Analyzer no container Linux usa abertura de arquivos sem seguir links (inclusive diretórios intermediários), aceita apenas arquivos regulares e mantém o suporte a UTF-8/16/32. Há dois workers ativos e seis posições de espera por processo Python. Execute uma única instância de Uvicorn por volume de workspace. + +O clone tem limites de memória virtual, CPU, tamanho por arquivo e duração; seu grupo de processos é encerrado em cancelamento ou excesso. O espaço por repositório e pelo workspace é monitorado a cada 100 ms durante o clone: pode haver excesso transitório entre medições, portanto isso não substitui uma quota de filesystem para um teto estrito de bytes. Compose limita o serviço Python a 2 CPUs, 1 GiB e 128 processos. Ollama é um serviço separado. Os valores de fila/clone estão em .env.example. diff --git a/backend/package-lock.json b/backend/package-lock.json index aad4283..eac38f1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,10 +8,12 @@ "name": "sinapse-backend", "version": "0.1.0", "dependencies": { + "@types/multer": "^2.3.0", "axios": "^1.20.0", "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", + "multer": "^2.4.0", "pg": "^8.13.1", "pgvector": "^0.2.0", "swagger-jsdoc": "^6.3.0", @@ -540,7 +542,6 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -551,7 +552,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -571,7 +571,6 @@ "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -583,7 +582,6 @@ "version": "5.1.3", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -596,7 +594,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -605,11 +602,19 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.3.0.tgz", + "integrity": "sha512-i7STIm9V4K2MPH4ZYMtrZAwNxs3kglk2LgleaTuB9pUXgADBcQYQuYMd7+6xgTIxfoggIkFA/DkkvycdZpIARA==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -631,21 +636,18 @@ "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -655,7 +657,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -758,6 +759,12 @@ } } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -833,6 +840,17 @@ "node": "20 || >=22" } }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1704,6 +1722,24 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1937,9 +1973,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -1947,6 +1983,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { @@ -2207,6 +2247,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/swagger-jsdoc": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.3.0.tgz", @@ -2319,7 +2367,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { diff --git a/backend/package.json b/backend/package.json index 6a9948c..5bbfc96 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,16 +13,20 @@ "start": "node dist/index.js", "typecheck": "tsc --noEmit", "migrate": "tsx src/database/migrate.ts", + "bootstrap:admin": "tsx scripts/bootstrap-admin.mts", "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": { + "@types/multer": "^2.3.0", "axios": "^1.20.0", "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", + "multer": "^2.4.0", "pg": "^8.13.1", "pgvector": "^0.2.0", "swagger-jsdoc": "^6.3.0", diff --git a/backend/scripts/apply-native-no-vector.mjs b/backend/scripts/apply-native-no-vector.mjs new file mode 100644 index 0000000..18be8ae --- /dev/null +++ b/backend/scripts/apply-native-no-vector.mjs @@ -0,0 +1,58 @@ +// One-off script for native (non-Docker) local setup without pgvector. +// Applies database/init.sql + migrations/*.sql to a real local Postgres, +// stripping the pgvector extension/column/index (same shim used by +// migration-test-utils.ts for the test suite), so embeddings fall back to +// real[] storage. Vector similarity search will not work; full-text search +// and the rest of the app function normally. +import pg from "pg"; +import { readFileSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const REPO = resolve(new URL(".", import.meta.url).pathname.slice(1), "../.."); +const dir = join(REPO, "database/migrations"); + +const client = new pg.Client({ + host: "localhost", + port: 5432, + user: "sinapse", + password: "sinapse_dev_password", + database: "sinapse", +}); +await client.connect(); + +const shim = (sql) => + sql + .replace(/CREATE EXTENSION IF NOT EXISTS vector;/g, "") + .replace(/vector\(1024\)/g, "real[]") + .replace(/^.*USING hnsw.*$/gm, "") + .replace(/embedding <=> \$\d+::vector/g, "0") + .replace(/::vector/g, ""); + +const initSql = shim(readFileSync(join(REPO, "database/init.sql"), "utf8")); +const files = readdirSync(dir) + .filter((f) => /^\d+_.*\.sql$/.test(f)) + .sort(); + +await client.query( + "CREATE TABLE IF NOT EXISTS _schema_migrations (version VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP)", +); + +for (const file of files) { + const already = await client.query("SELECT 1 FROM _schema_migrations WHERE version=$1", [file]); + if (already.rowCount) { + console.log("skip", file); + continue; + } + const sql = shim(readFileSync(join(dir, file), "utf8")).replaceAll("\\ir ../init.sql", initSql); + try { + await client.query(sql); + await client.query("INSERT INTO _schema_migrations (version) VALUES ($1)", [file]); + console.log("applied", file); + } catch (error) { + console.error("FAILED", file, error.message); + process.exitCode = 1; + break; + } +} + +await client.end(); diff --git a/backend/scripts/bootstrap-admin.mts b/backend/scripts/bootstrap-admin.mts new file mode 100644 index 0000000..784ab2a --- /dev/null +++ b/backend/scripts/bootstrap-admin.mts @@ -0,0 +1,67 @@ +/** + * Cria (ou promove) o usuário administrador local de desenvolvimento/teste. + * + * O cadastro público (POST /api/v1/auth/register) só cria contas "dev" — + * criar "po"/"admin" exige já estar autenticado como admin, de propósito + * (evita que qualquer pessoa se auto-promova). Isso cria um impasse no + * primeiro acesso: sem nenhum admin, ninguém consegue criar o primeiro. + * Este script resolve isso diretamente no banco, reaproveitando o mesmo + * hashPassword() que o backend usa (scrypt), então o hash gerado é válido + * para login normal pela API. + * + * Uso: + * cd backend + * npx tsx scripts/bootstrap-admin.mts + * npx tsx scripts/bootstrap-admin.mts --email=outro@exemplo.com --password=Outra@123 --nome="Outro Nome" + * + * Idempotente: se o e-mail já existir, apenas garante role='admin' e NÃO + * sobrescreve a senha (a menos que --password seja passado explicitamente + * em conjunto com --force-password). + */ +import { pool } from "../src/database/db.js"; +import { hashPassword } from "../src/modules/auth/pssword.service.js"; + +const DEFAULT_EMAIL = "admin@sinapse.local"; +const DEFAULT_PASSWORD = "Admin@123"; +const DEFAULT_NOME = "Admin Local"; + +function argValue(flag: string): string | undefined { + const prefix = `--${flag}=`; + const match = process.argv.find((arg) => arg.startsWith(prefix)); + return match?.slice(prefix.length); +} + +const email = (argValue("email") ?? DEFAULT_EMAIL).trim().toLowerCase(); +const password = argValue("password") ?? DEFAULT_PASSWORD; +const nome = argValue("nome") ?? DEFAULT_NOME; +const forcePassword = process.argv.includes("--force-password"); + +try { + const existing = await pool.query<{ id: string; role: string }>( + "SELECT id, role FROM usuario WHERE email = $1", + [email], + ); + + if (existing.rows[0]) { + const { id, role } = existing.rows[0]; + if (forcePassword) { + const senha_hash = await hashPassword(password); + await pool.query("UPDATE usuario SET role = 'admin', senha_hash = $2 WHERE id = $1", [id, senha_hash]); + console.log(`[bootstrap-admin] Conta existente (${email}) promovida a admin e senha redefinida.`); + } else if (role !== "admin") { + await pool.query("UPDATE usuario SET role = 'admin' WHERE id = $1", [id]); + console.log(`[bootstrap-admin] Conta existente (${email}) promovida a admin. Senha mantida.`); + } else { + console.log(`[bootstrap-admin] ${email} já é admin. Nada a fazer.`); + } + } else { + const senha_hash = await hashPassword(password); + await pool.query( + "INSERT INTO usuario (nome, email, senha_hash, role) VALUES ($1, $2, $3, 'admin')", + [nome, email, senha_hash], + ); + console.log(`[bootstrap-admin] Criado admin ${email} / senha: ${password}`); + } +} finally { + await pool.end(); +} diff --git a/backend/scripts/validate-s1.mts b/backend/scripts/validate-s1.mts index 9476671..ad31e80 100644 --- a/backend/scripts/validate-s1.mts +++ b/backend/scripts/validate-s1.mts @@ -5,12 +5,16 @@ import { once } from "node:events"; import { app } from "../src/index.js"; import { pool } from "../src/database/db.js"; import { env } from "../src/config/env.js"; +import { assertValidationDatabase } from "../src/database/validation-target.js"; import { hashPassword } from "../src/modules/auth/pssword.service.js"; -import { apiRequest, readUser } from "../../frontend/src/auth/api.ts"; -import { createProject, getProject, listProjects } from "../../frontend/src/projects/api.ts"; +import { apiRequest, readUser } from "../../frontend/src/api/api_auth.ts"; +import { createProject, getProject, listProjects } from "../../frontend/src/api/api_projects.ts"; assert.equal(env.NODE_ENV, "test", "Execute com NODE_ENV=test"); -assert.match(env.POSTGRES_DB, /_s1_validation$/, "Use um banco descartável terminado em _s1_validation"); +// Check the connected server, including DATABASE_URL and PostgreSQL defaults. +const target = await pool.query('SELECT current_database() AS database'); +try { assertValidationDatabase(target.rows[0]?.database); } +catch (error) { await pool.end(); throw error; } const server = app.listen(0, "127.0.0.1"); await once(server, "listening"); const address = server.address(); diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..ebfd997 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,130 @@ +import { createProjectAccessRouter } from "./middleware/projectAccess.js"; +import express, { Request, Response } from "express"; +import cors from "cors"; +import swaggerUi from 'swagger-ui-express'; +import { env } from "./config/env.js"; +import { checkDatabaseConnection } from "./database/db.js"; +import { swaggerSpec } from "./config/swagger.config.js"; +import { projectsRouter } from "./modules/projects/projects.routes.js"; +import { epicsRouter } from "./modules/epics/epics.routes.js"; +import { featuresRouter } from "./modules/features/features.routes.js"; +import { pbisRouter } from "./modules/pbis/pbis.routes.js"; +import { criteriaRouter } from "./modules/criteria/criteria.routes.js"; +import qualityRouter from "./modules/quality/quality.routes.js"; +import { epicsCompatRouter } from "./modules/epics/epics.compat.routes.js"; +import { repoAnalysesRouter } from './modules/repo-analyses/repo-analyses.routes'; +import { epicDecisionsRouter, featureDecisionsRouter, pbiDecisionsRouter, projectDecisionsRouter } from "./modules/decisions/decisions.routes.js"; +import { backlogSearchRouter } from "./modules/backlog-search/backlog-search.routes.js"; +import { documentsRouter } from "./modules/documents/documents.routes.js"; +import { chunksRouter } from "./modules/documents/chunks.routes.js"; +import { documentsService } from "./modules/documents/documents.service.js"; +import { authRouter } from "./modules/auth/auth.routes.js"; +import { searchRouter } from "./modules/search/search.routes.js"; +import { chatRouter } from "./modules/chat/chat.routes.js"; +import { developersRouter } from "./modules/developers/developers.routes.js"; +import { technologiesRouter } from "./modules/technologies/technologies.routes.js"; +import { adminRouter } from "./modules/admin/admin.routes.js"; +import { auditRouter } from "./modules/audit/audit.routes.js"; +import { errorHandler } from "./middleware/errorHandler.js"; +import { requireAuth } from "./middleware/requireAuth.js"; + +export const app = express(); + +app.use(cors()); +app.use(express.json()); + +app.use("/api/v1/auth", authRouter); + +// Health Check Endpoint +app.get("/health", async (_req: Request, res: Response) => { + const dbHealthy = await checkDatabaseConnection(); + const documents = dbHealthy ? await documentsService.health().catch(() => null) : null; + + res.status(dbHealthy ? 200 : 503).json({ + status: dbHealthy ? "healthy" : "degraded", + timestamp: new Date().toISOString(), + service: "sinapse-backend", + version: "0.1.0", + dependencies: { + database: dbHealthy ? "connected" : "disconnected", + aiService: env.AI_SERVICE_URL, + }, + documents, + }); +}); + +app.use("/api/v1", createProjectAccessRouter()); +app.use("/api", createProjectAccessRouter()); + +// Projects API Endpoints (v1 e alias) +app.use("/api/v1/projects", requireAuth, projectsRouter); +app.use("/api/projects", requireAuth, projectsRouter); +// Compatibilidade das rotas de épicos da implementação anterior +app.use("/api/v1", requireAuth, epicsCompatRouter); +app.use("/api", requireAuth, epicsCompatRouter); + +// Hierarquia do backlog: épicos, features, PBIs e critérios de aceitação (S1-05/06/07/10) +app.use("/api/v1/epics", requireAuth, epicsRouter); +app.use("/api/v1/features", requireAuth, featuresRouter); +app.use("/api/v1/pbis", requireAuth, pbisRouter); +app.use("/api/v1/criteria", requireAuth, criteriaRouter); +app.use("/api/v1/quality", qualityRouter); +app.use("/api/v1/audit", auditRouter); + +// Busca híbrida e acervo +app.use("/api/v1/search", searchRouter); + +// Chat assistivo e conversas +app.use("/api/v1/chat", chatRouter); + +// Desenvolvedores e competências +app.use("/api/v1/developers", developersRouter); +app.use("/api/v1/technologies", requireAuth, technologiesRouter); + +// Painel administrativo +app.use("/api/v1/admin", adminRouter); + +// Repo analyzer +app.use('/api/v1/projects/:projectId/repo-analyses', repoAnalysesRouter); + +// Decisões em qualquer nível da hierarquia (S1-18) +app.use("/api/v1/projects/:entityId/decisions", projectDecisionsRouter); +app.use("/api/v1/epics/:entityId/decisions", epicDecisionsRouter); +app.use("/api/v1/features/:entityId/decisions", featureDecisionsRouter); +app.use("/api/v1/pbis/:entityId/decisions", pbiDecisionsRouter); + +// Busca textual no backlog (S1-17) +app.use("/api/v1/projects/:projectId/backlog-search", backlogSearchRouter); + +// Documentos do projeto (S1-19/S1-20/S1-22) +app.use("/api/v1/projects/:projectId/documents", requireAuth, documentsRouter); +// Server-to-server: pipeline n8n persistindo chunks indexados do documento. +app.use("/api/v1/projects/:projectId/documents/:documentId/chunks", chunksRouter); + +// Swagger Documentation +app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); + +// Root Information Endpoint +app.get("/api/v1", (_req: Request, res: Response) => { + res.json({ + name: "Sinapse API", + version: "v1", + description: "API de Backend do Sinapse - Base Inteligente de Requisitos", + documentation: "/docs", + modules: [ + { name: "projects", status: "ready" }, + { name: "epics", status: "ready" }, + { name: "features", status: "ready" }, + { name: "pbis", status: "ready" }, + { name: "criteria", status: "ready" }, + { name: "decisions", status: "in_development" }, + { name: "ai-bridge", status: "ready" }, + ], + }); +}); + +// Global Error Handler +app.use(errorHandler); + + +export default app; diff --git a/backend/src/config/env.test.ts b/backend/src/config/env.test.ts index a71a1e7..912dd9b 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,43 @@ 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("produção rejeita o token padrão de desenvolvimento do n8n", () => { + 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: "a".repeat(32), + N8N_INGEST_TOKEN: "sinapse-dev-ingest-token", + }, + encoding: "utf8", + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /N8N_INGEST_TOKEN/); +}); + +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..c49c8f3 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -17,8 +17,12 @@ const envSchema = z.object({ POSTGRES_DB: z.string().default("sinapse"), POSTGRES_HOST: z.string().default("localhost"), POSTGRES_PORT: z.coerce.number().default(5432), + AI_SERVICE_TOKEN: z.string().default(""), 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 +31,36 @@ 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_INGEST_WEBHOOK_URL: z.string().optional(), DOCUMENT_EVENTS_WEBHOOK_URL: z.string().optional(), + // Token server-to-server para o backend aceitar POST .../documents/.../chunks + // vindo do workflow do n8n (ferramenta de debug manual). Em dev tem default + // pra nao bloquear o fluxo local; em prod obrigatorio sobrescrever. + N8N_INGEST_TOKEN: z.string().min(8).default("sinapse-dev-ingest-token"), +}).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.", + }); + } + if (value.NODE_ENV === "production" && value.N8N_INGEST_TOKEN === "sinapse-dev-ingest-token") { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["N8N_INGEST_TOKEN"], + message: "Configure um segredo aleatório privado para N8N_INGEST_TOKEN em produção; o valor padrão de desenvolvimento não é aceito.", + }); + } }); -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/db.test.ts b/backend/src/database/db.test.ts new file mode 100644 index 0000000..a63d19c --- /dev/null +++ b/backend/src/database/db.test.ts @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Pool } from 'pg'; +import { checkDatabaseConnection } from './db'; + +test('healthcheck devolve a conexão no sucesso e descarta no erro de consulta', async () => { + for (const failed of [false, true]) { + const releases: boolean[] = []; + const db = { connect: async () => ({ + query: async () => { if (failed) throw new Error('query failed'); }, + release: (discard: boolean) => releases.push(discard), + }) } as unknown as Pool; + assert.equal(await checkDatabaseConnection(db), !failed); + assert.deepEqual(releases, [failed]); + } +}); diff --git a/backend/src/database/db.ts b/backend/src/database/db.ts index afecef3..dd5f9fc 100644 --- a/backend/src/database/db.ts +++ b/backend/src/database/db.ts @@ -1,25 +1,21 @@ -import { Pool } from "pg"; +import { Pool, type PoolClient } from "pg"; import { env } from "../config/env.js"; +import { databasePoolConfig } from './pool-config.js'; -export const pool = new Pool({ - host: env.POSTGRES_HOST, - port: env.POSTGRES_PORT, - user: env.POSTGRES_USER, - password: env.POSTGRES_PASSWORD, - database: env.POSTGRES_DB, - max: 20, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 2000, -}); +export const pool = new Pool(databasePoolConfig(env)); -export async function checkDatabaseConnection(): Promise { +export async function checkDatabaseConnection(db: Pick = pool): Promise { + let client: PoolClient | undefined; + let failed = false; try { - const client = await pool.connect(); + client = await db.connect(); await client.query("SELECT 1"); - client.release(); return true; } catch (error) { + failed = true; console.error("[Database] Failed to connect to PostgreSQL:", error); return false; + } finally { + client?.release(failed); } } diff --git a/backend/src/database/pool-config.ts b/backend/src/database/pool-config.ts new file mode 100644 index 0000000..06842e5 --- /dev/null +++ b/backend/src/database/pool-config.ts @@ -0,0 +1,25 @@ +import type { PoolConfig } from 'pg'; + +interface DatabaseEnvironment { + DATABASE_URL?: string; + POSTGRES_HOST: string; + POSTGRES_PORT: number; + POSTGRES_USER: string; + POSTGRES_PASSWORD: string; + POSTGRES_DB: string; +} + +export function databasePoolConfig(env: DatabaseEnvironment): PoolConfig { + return { + ...(env.DATABASE_URL ? { connectionString: env.DATABASE_URL } : { + host: env.POSTGRES_HOST, + port: env.POSTGRES_PORT, + user: env.POSTGRES_USER, + password: env.POSTGRES_PASSWORD, + database: env.POSTGRES_DB, + }), + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + }; +} 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/database/transaction.test.ts b/backend/src/database/transaction.test.ts new file mode 100644 index 0000000..6cac7b1 --- /dev/null +++ b/backend/src/database/transaction.test.ts @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { Pool, PoolClient } from "pg"; +import { withTransaction } from "./transaction.js"; + +test("a failed rollback discards the connection without hiding the original error", async () => { + const original = new Error("write failed"); + const disconnected = new Error("connection lost"); + const releases: unknown[] = []; + const client = { + query: async (sql: string) => { if (sql === "ROLLBACK") throw disconnected; }, + release: (error?: unknown) => { releases.push(error); }, + } as unknown as PoolClient; + const pool = { connect: async () => client } as unknown as Pool; + + await assert.rejects(withTransaction(pool, async () => { throw original; }), error => error === original); + assert.deepEqual(releases, [disconnected]); +}); + +test("a commit failure rejects the operation and releases its connection", async () => { + const failure = new Error("commit failed"); + const queries: string[] = []; + let releases = 0; + const client = { + query: async (sql: string) => { + queries.push(sql); + if (sql === "COMMIT") throw failure; + }, + release: () => { releases++; }, + } as unknown as PoolClient; + const pool = { connect: async () => client } as unknown as Pool; + + await assert.rejects(withTransaction(pool, async () => "result"), error => error === failure); + assert.equal(queries.at(-1), "ROLLBACK"); + assert.equal(releases, 1); +}); diff --git a/backend/src/database/transaction.ts b/backend/src/database/transaction.ts new file mode 100644 index 0000000..5de27ab --- /dev/null +++ b/backend/src/database/transaction.ts @@ -0,0 +1,28 @@ +import type { Pool, PoolClient } from "pg"; + +/** Keeps every query in a unit of work on the same connection. */ +export async function withTransaction( + pool: Pool, + work: (client: PoolClient) => Promise, + mode: "write" | "snapshot" = "write", +): Promise { + const client = await pool.connect(); + let connectionError: Error | boolean | undefined; + try { + await client.query(mode === "snapshot" ? "BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY" : "BEGIN"); + const result = await work(client); + await client.query("COMMIT"); + return result; + } catch (error) { + try { + await client.query("ROLLBACK"); + } catch (rollbackError) { + // A broken connection must not be returned to the pool for reuse. + connectionError = rollbackError instanceof Error ? rollbackError : true; + throw error; + } + throw error; + } finally { + client.release(connectionError); + } +} diff --git a/backend/src/database/validation-target.test.ts b/backend/src/database/validation-target.test.ts new file mode 100644 index 0000000..015849b --- /dev/null +++ b/backend/src/database/validation-target.test.ts @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { assertValidationDatabase } from './validation-target'; +import { databasePoolConfig } from './pool-config'; + +test('validação recusa o banco efetivo mesmo com POSTGRES_DB de teste', () => { + const config = databasePoolConfig({ DATABASE_URL: 'postgresql://localhost/production', + POSTGRES_DB: 'sinapse_s1_validation', POSTGRES_HOST: 'localhost', POSTGRES_PORT: 5432, + POSTGRES_USER: 'test', POSTGRES_PASSWORD: 'test' }); + assert.equal(config.connectionString, 'postgresql://localhost/production'); + assert.equal(config.database, undefined); + assert.throws(() => assertValidationDatabase('production')); + assert.throws(() => assertValidationDatabase(undefined)); + assert.doesNotThrow(() => assertValidationDatabase('sinapse_s1_validation')); +}); diff --git a/backend/src/database/validation-target.ts b/backend/src/database/validation-target.ts new file mode 100644 index 0000000..986d6ec --- /dev/null +++ b/backend/src/database/validation-target.ts @@ -0,0 +1,5 @@ +export function assertValidationDatabase(database: unknown): void { + if (typeof database !== 'string' || !/^[a-zA-Z0-9_-]+_s1_validation$/.test(database)) { + throw new Error('Use um banco descartável terminado em _s1_validation'); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 2bcfaa1..163cd3e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,127 +1,16 @@ -import express, { Request, Response } from "express"; -import cors from "cors"; -import swaggerUi from 'swagger-ui-express'; +import app from "./app.js"; +import { startRepoAnalysesWorker } from "./modules/repo-analyses/repo-analyses.service.js"; import { env } from "./config/env.js"; -import { checkDatabaseConnection } from "./database/db.js"; -import { swaggerSpec } from "./config/swagger.config.js"; -import { projectsRouter } from "./modules/projects/projects.routes.js"; -import { epicsRouter } from "./modules/epics/epics.routes.js"; -import { featuresRouter } from "./modules/features/features.routes.js"; -import { pbisRouter } from "./modules/pbis/pbis.routes.js"; -import { criteriaRouter } from "./modules/criteria/criteria.routes.js"; -import qualityRouter from "./modules/quality/quality.routes.js"; -import { epicsCompatRouter } from "./modules/epics/epics.compat.routes.js"; -import { repoAnalysesRouter } from './modules/repo-analyses/repo-analyses.routes'; -import { epicDecisionsRouter, featureDecisionsRouter, pbiDecisionsRouter, projectDecisionsRouter } from "./modules/decisions/decisions.routes.js"; -import { backlogSearchRouter } from "./modules/backlog-search/backlog-search.routes.js"; -import { documentsRouter } from "./modules/documents/documents.routes.js"; -import { documentsService, startDocumentsBackgroundWorker } from "./modules/documents/documents.service.js"; -import { authRouter } from "./modules/auth/auth.routes.js"; -import { searchRouter } from "./modules/search/search.routes.js"; -import { chatRouter } from "./modules/chat/chat.routes.js"; -import { developersRouter } from "./modules/developers/developers.routes.js"; -import { technologiesRouter } from "./modules/technologies/technologies.routes.js"; -import { adminRouter } from "./modules/admin/admin.routes.js"; -import { auditRouter } from "./modules/audit/audit.routes.js"; -import { errorHandler } from "./middleware/errorHandler.js"; -import { requireAuth } from "./middleware/requireAuth.js"; +import { startDocumentsBackgroundWorker } from "./modules/documents/documents.service.js"; -export const app = express(); - -app.use(cors()); -app.use(express.json()); - -app.use("/api/v1/auth", authRouter); - -// Health Check Endpoint -app.get("/health", async (_req: Request, res: Response) => { - const dbHealthy = await checkDatabaseConnection(); - const documents = dbHealthy ? await documentsService.health().catch(() => null) : null; - - res.status(dbHealthy ? 200 : 503).json({ - status: dbHealthy ? "healthy" : "degraded", - timestamp: new Date().toISOString(), - service: "sinapse-backend", - version: "0.1.0", - dependencies: { - database: dbHealthy ? "connected" : "disconnected", - aiService: env.AI_SERVICE_URL, - }, - documents, - }); -}); - -// Projects API Endpoints (v1 e alias) -app.use("/api/v1/projects", requireAuth, projectsRouter); -app.use("/api/projects", requireAuth, projectsRouter); -// Compatibilidade das rotas de épicos da implementação anterior -app.use("/api/v1", requireAuth, epicsCompatRouter); -app.use("/api", requireAuth, epicsCompatRouter); - -// Hierarquia do backlog: épicos, features, PBIs e critérios de aceitação (S1-05/06/07/10) -app.use("/api/v1/epics", requireAuth, epicsRouter); -app.use("/api/v1/features", requireAuth, featuresRouter); -app.use("/api/v1/pbis", requireAuth, pbisRouter); -app.use("/api/v1/criteria", requireAuth, criteriaRouter); -app.use("/api/v1/quality", qualityRouter); -app.use("/api/v1/audit", auditRouter); - -// Busca híbrida e acervo -app.use("/api/v1/search", searchRouter); - -// Chat assistivo e conversas -app.use("/api/v1/chat", chatRouter); - -// Desenvolvedores e competências -app.use("/api/v1/developers", developersRouter); -app.use("/api/v1/technologies", requireAuth, technologiesRouter); - -// Painel administrativo -app.use("/api/v1/admin", adminRouter); - -// Repo analyzer -app.use('/api/v1/projects/:projectId/repo-analyses', repoAnalysesRouter); - -// Decisões em qualquer nível da hierarquia (S1-18) -app.use("/api/v1/projects/:entityId/decisions", projectDecisionsRouter); -app.use("/api/v1/epics/:entityId/decisions", epicDecisionsRouter); -app.use("/api/v1/features/:entityId/decisions", featureDecisionsRouter); -app.use("/api/v1/pbis/:entityId/decisions", pbiDecisionsRouter); - -// Busca textual no backlog (S1-17) -app.use("/api/v1/projects/:projectId/backlog-search", backlogSearchRouter); - -// Documentos do projeto (S1-19/S1-20/S1-22) -app.use("/api/v1/projects/:projectId/documents", requireAuth, documentsRouter); - -// Swagger Documentation -app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); - -// Root Information Endpoint -app.get("/api/v1", (_req: Request, res: Response) => { - res.json({ - name: "Sinapse API", - version: "v1", - description: "API de Backend do Sinapse - Base Inteligente de Requisitos", - documentation: "/docs", - modules: [ - { name: "projects", status: "ready" }, - { name: "epics", status: "ready" }, - { name: "features", status: "ready" }, - { name: "pbis", status: "ready" }, - { name: "criteria", status: "ready" }, - { name: "decisions", status: "in_development" }, - { name: "ai-bridge", status: "ready" }, - ], - }); -}); - -// Global Error Handler -app.use(errorHandler); +export { app }; const PORT = env.PORT; if (env.NODE_ENV !== "test") { + // Worker unico pos-merge S2-01: processa documentos pendentes via claim/lease + // (processPendingDocuments) + flush de eventos + reconciliacao de arquivos. startDocumentsBackgroundWorker(); + startRepoAnalysesWorker(); if (!env.DOCUMENT_EVENTS_WEBHOOK_URL?.trim()) { console.warn("[Documents] DOCUMENT_EVENTS_WEBHOOK_URL is not configured; removal events will retry until a consumer is configured."); } diff --git a/backend/src/middleware/projectAccess.ts b/backend/src/middleware/projectAccess.ts new file mode 100644 index 0000000..b502417 --- /dev/null +++ b/backend/src/middleware/projectAccess.ts @@ -0,0 +1,22 @@ +import { Router, type RequestHandler } from "express"; +import { requireAuth } from "./requireAuth.js"; +import { ProjectAccess } from "../modules/projects/project-access.js"; + +// Mounted before business routers, on both /api/v1 and the legacy /api prefix. +export function createProjectAccessRouter(access = new ProjectAccess(), authenticate: RequestHandler = requireAuth) { + const router = Router(); + const guard = (kind: string, idParam = "id"): RequestHandler => async (req,_res,next) => { + try { await access.assertEntity(req.auth!.id,kind || String(req.params.kind),String(req.params[idParam])); next(); } + catch(error) { next(error); } + }; + for (const [path,kind] of [["projects","projeto"],["epics","epico"],["epicos","epico"],["features","feature"],["pbis","pbi"],["criteria","criterio"]]) { + router.use('/'+path+'/:id',authenticate,guard(kind)); + } + router.use('/quality/pbis/:id',authenticate,guard('pbi')); + router.use('/audit/:kind/:id',authenticate,guard('')); + router.get('/criteria',authenticate,async (req,_res,next) => { + try { await access.assertEntity(req.auth!.id,String(req.query.entidade_tipo),String(req.query.entidade_id)); next(); } + catch(error) { next(error); } + }); + return router; +} diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts new file mode 100644 index 0000000..4faa666 --- /dev/null +++ b/backend/src/modules/admin/admin.controller.ts @@ -0,0 +1,36 @@ +import type { NextFunction, Request, Response } from "express"; +import { AdminService } from "./admin.service.js"; +import { ingestionObservabilityRepository, type IngestionObservabilityRepository } from "./ingestion-observability.js"; + +export class AdminController { + constructor( + private readonly service: AdminService = new AdminService(), + private readonly ingestion: IngestionObservabilityRepository = ingestionObservabilityRepository, + ) {} + + stats = async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + res.json(await this.service.stats()); + } catch (error) { + next(error); + } + }; + + loadDemo = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + res.json(await this.service.loadDemo(req.body, req.auth!.id)); + } catch (error) { + next(error); + } + }; + + ingestionSnapshot = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const limitParam = typeof req.query.limit === "string" ? Number(req.query.limit) : 25; + const limit = Number.isInteger(limitParam) && limitParam >= 1 && limitParam <= 100 ? limitParam : 25; + res.json(await this.ingestion.snapshot(limit)); + } catch (error) { + next(error); + } + }; +} diff --git a/backend/src/modules/admin/admin.repository.ts b/backend/src/modules/admin/admin.repository.ts new file mode 100644 index 0000000..17de41c --- /dev/null +++ b/backend/src/modules/admin/admin.repository.ts @@ -0,0 +1,59 @@ +import { Pool } from "pg"; +import { pool } from "../../database/db.js"; +import { withTransaction } from "../../database/transaction.js"; +import { auditService } from "../audit/audit.service.js"; +import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; + +export interface AdminCounts { + projetos: number; + epicos: number; + features: number; + pbis: number; + documentos: number; + chunksIndexados: number; +} + +export class AdminRepository { + constructor(private readonly db: Pool = pool) { } + + async counts(): Promise { + const { rows } = await this.db.query( + `SELECT + (SELECT COUNT(*)::int FROM projeto) AS projetos, + (SELECT COUNT(*)::int FROM epico) AS epicos, + (SELECT COUNT(*)::int FROM feature) AS features, + (SELECT COUNT(*)::int FROM pbi) AS pbis, + (SELECT COUNT(*)::int FROM documento) AS documentos, + (SELECT COUNT(*)::int FROM chunk WHERE embedding IS NOT NULL) AS "chunksIndexados"`, + ); + return rows[0]; + } + + async insertDemoChunks(projectId: string, userId: string): Promise { + return withTransaction(this.db, async (client) => { + + await lockHierarchy(client, "projeto", projectId); + await assertWritable(client, "projeto", projectId); + const result = await client.query( + `INSERT INTO chunk (id, projeto_id, entidade_tipo, entidade_id, texto, metadados_json) + SELECT md5($1::text || ':sinapse-demo-v1:' || demo.key)::uuid, + $1::uuid, 'demonstracao', md5($1::text || ':sinapse-demo-v1:' || demo.key)::uuid, + demo.texto, jsonb_build_object('fonte', 'sinapse-demo-v1', 'demonstracao', true, 'chave', demo.key) + FROM (VALUES + ('arquitetura', 'Exemplo demonstrativo: uma base de requisitos pode organizar projetos, épicos, features e PBIs.'), + ('decisao', 'Exemplo demonstrativo: registre o contexto, as alternativas e a justificativa de cada decisão técnica.') + ) AS demo(key, texto) + ON CONFLICT (id) DO NOTHING`, + [projectId], + ); + const inserted = result.rowCount ?? 0; + if (inserted > 0) await auditService.record({ + usuario_id: userId, entidade_tipo: "projeto", entidade_id: projectId, + acao: "CARREGAR_DEMONSTRACAO", dados_json: { versao: "sinapse-demo-v1", inseridos: inserted }, + }, client); + + return inserted; + + }); + } +} diff --git a/backend/src/modules/admin/admin.routes.ts b/backend/src/modules/admin/admin.routes.ts index 5359d2e..e855b3b 100644 --- a/backend/src/modules/admin/admin.routes.ts +++ b/backend/src/modules/admin/admin.routes.ts @@ -1,60 +1,20 @@ -import { Router, Request, Response, NextFunction } from "express"; -import { pool } from "../../database/db.js"; +import { Router, type RequestHandler } from "express"; import { requireAuth } from "../../middleware/requireAuth.js"; import { requireRole } from "../../middleware/requireRole.js"; - -export const adminRouter = Router(); - -adminRouter.use(requireAuth); -adminRouter.use(requireRole("admin")); - -// GET /api/v1/admin/stats - Estatísticas reais do banco PostgreSQL -adminRouter.get("/stats", async (_req: Request, res: Response, next: NextFunction) => { - try { - const projectsCount = await pool.query("SELECT COUNT(*) FROM projeto"); - const epicsCount = await pool.query("SELECT COUNT(*) FROM epico"); - const featuresCount = await pool.query("SELECT COUNT(*) FROM feature"); - const pbisCount = await pool.query("SELECT COUNT(*) FROM pbi"); - const documentsCount = await pool.query("SELECT COUNT(*) FROM documento"); - const chunksCount = await pool.query("SELECT COUNT(*) FROM chunk"); - - res.json({ - projetos: parseInt(projectsCount.rows[0].count, 10), - epicos: parseInt(epicsCount.rows[0].count, 10), - features: parseInt(featuresCount.rows[0].count, 10), - pbis: parseInt(pbisCount.rows[0].count, 10), - documentos: parseInt(documentsCount.rows[0].count, 10), - chunksIndexados: parseInt(chunksCount.rows[0].count, 10), - statusSistema: "operacional", - }); - } catch (error) { - next(error); - } -}); - -// POST /api/v1/admin/ingest-seed - Reindexação de acervo do sistema -adminRouter.post("/ingest-seed", async (_req: Request, res: Response, next: NextFunction) => { - try { - // Insere registros demonstrativos reais de acervo se o acervo estiver zerado - const projectRes = await pool.query("SELECT id FROM projeto LIMIT 1"); - if (projectRes.rows.length === 0) { - res.status(400).json({ error: "Crie ao menos um projeto antes de reindexar." }); - return; - } - - const projectId = projectRes.rows[0].id; - - await pool.query( - `INSERT INTO chunk (projeto_id, entidade_tipo, entidade_id, texto, metadados_json) - VALUES - ($1, 'documento', $1, 'Arquitetura Sinapse: O sistema utiliza PostgreSQL com extensão pgvector para busca vetorial híbrida.', '{"fonte":"docs/Architecture/README.md"}'), - ($1, 'decisao', $1, 'Decisão de Arquitetura: O runtime de IA roda 100% local com Ollama (bge-m3 + qwen2.5:1.5b).', '{"fonte":"docs/PRD-PRO4TECH.md"}') - ON CONFLICT DO NOTHING`, - [projectId] - ); - - res.json({ message: "Reindexação e carga inicial do acervo concluídas com sucesso!", status: "sucesso" }); - } catch (error) { - next(error); - } -}); +import { AdminController } from "./admin.controller.js"; + +export function createAdminRouter(controller = new AdminController(), authenticate: RequestHandler = requireAuth) { + const router = Router(); + router.use(authenticate); + router.use(requireRole("admin")); + router.get("/stats", controller.stats); + router.post("/demo-seed", controller.loadDemo); + // Compatibility alias: explicit project is required on both routes. + router.post("/ingest-seed", controller.loadDemo); + // Observabilidade do pipeline de ingestao (substitui o editor do n8n para + // o admin acompanhar o worker). Query opcional: limit=1..100 (default 25). + router.get("/ingestion", controller.ingestionSnapshot); + return router; +} + +export const adminRouter = createAdminRouter(); diff --git a/backend/src/modules/admin/admin.service.ts b/backend/src/modules/admin/admin.service.ts new file mode 100644 index 0000000..a58f0bd --- /dev/null +++ b/backend/src/modules/admin/admin.service.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { ValidationError } from "../../shared/errors.js"; +import { AdminRepository } from "./admin.repository.js"; + +const demoSeedSchema = z.object({ projeto_id: z.string().uuid("Selecione um projeto válido para a carga demonstrativa.") }).strict(); + +export class AdminService { + constructor(private readonly repository: Pick = new AdminRepository()) {} + + async stats() { + return { ...await this.repository.counts(), statusSistema: "operacional" }; + } + + async loadDemo(input: unknown, userId: string) { + const parsed = demoSeedSchema.safeParse(input); + if (!parsed.success) throw new ValidationError("Informe o projeto de destino da carga demonstrativa.", parsed.error.flatten()); + const inseridos = await this.repository.insertDemoChunks(parsed.data.projeto_id, userId); + return { + projeto_id: parsed.data.projeto_id, inseridos, status: "sucesso", + message: inseridos ? "Dados demonstrativos carregados. Nenhuma indexação vetorial foi executada." : "Este projeto já possui a carga demonstrativa. Nenhum trecho foi duplicado.", + }; + } +} diff --git a/backend/src/modules/admin/ingestion-observability.test.ts b/backend/src/modules/admin/ingestion-observability.test.ts new file mode 100644 index 0000000..8162210 --- /dev/null +++ b/backend/src/modules/admin/ingestion-observability.test.ts @@ -0,0 +1,91 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import express, { type RequestHandler } from "express"; +import { AddressInfo } from "node:net"; +import type { Server } from "node:http"; +import { createAdminRouter } from "./admin.routes.js"; +import { AdminController } from "./admin.controller.js"; +import { AdminService } from "./admin.service.js"; +import { IngestionObservabilityRepository, type IngestionSnapshot } from "./ingestion-observability.js"; +import { errorHandler } from "../../middleware/errorHandler.js"; + +class FakeAdminService extends AdminService { + constructor() { + super({ + async counts() { + return { projetos: 0, epicos: 0, features: 0, pbis: 0, documentos: 0, chunksIndexados: 0 }; + }, + async insertDemoChunks() { return 0; }, + }); + } +} + +class FakeIngestionRepository extends IngestionObservabilityRepository { + public lastLimit: number | null = null; + public response: IngestionSnapshot = { + counts: { pendente: 1, processando: 2, processado: 3, falha: 4 }, + recent: [], + active: [], + failed: [], + generated_at: "2026-10-09T00:00:00.000Z", + }; + async snapshot(limit = 25): Promise { + this.lastLimit = limit; + return this.response; + } +} + +const auth: RequestHandler = (req, res, next) => { + const role = req.headers["x-role"]; + if (role !== "admin" && role !== "po" && role !== "dev") { + res.status(401).json({ code: "UNAUTHORIZED" }); + return; + } + req.auth = { id: "user", nome: "Teste", email: "teste@example.com", role }; + next(); +}; + +async function withServer(repository: FakeIngestionRepository, run: (base: string) => Promise): Promise { + const app = express(); + app.use(express.json()); + const controller = new AdminController(new FakeAdminService(), repository); + app.use("/admin", createAdminRouter(controller, auth)); + app.use(errorHandler); + let server!: Server; + await new Promise((resolve, reject) => { server = app.listen(0, "127.0.0.1", (error) => error ? reject(error) : resolve()); }); + const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/admin`; + try { await run(base); } + finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } +} + +test("ingestion observability: exige admin", async () => { + await withServer(new FakeIngestionRepository(), async (base) => { + assert.equal((await fetch(`${base}/ingestion`)).status, 401); + assert.equal((await fetch(`${base}/ingestion`, { headers: { "x-role": "po" } })).status, 403); + assert.equal((await fetch(`${base}/ingestion`, { headers: { "x-role": "dev" } })).status, 403); + }); +}); + +test("ingestion observability: devolve o snapshot com contagens e listas", async () => { + const repo = new FakeIngestionRepository(); + await withServer(repo, async (base) => { + const res = await fetch(`${base}/ingestion`, { headers: { "x-role": "admin" } }); + assert.equal(res.status, 200); + const body = await res.json() as IngestionSnapshot; + assert.deepEqual(body.counts, { pendente: 1, processando: 2, processado: 3, falha: 4 }); + assert.equal(body.generated_at, "2026-10-09T00:00:00.000Z"); + assert.equal(repo.lastLimit, 25); + }); +}); + +test("ingestion observability: aceita limit customizado dentro de [1,100]", async () => { + const repo = new FakeIngestionRepository(); + await withServer(repo, async (base) => { + await fetch(`${base}/ingestion?limit=50`, { headers: { "x-role": "admin" } }); + assert.equal(repo.lastLimit, 50); + await fetch(`${base}/ingestion?limit=999`, { headers: { "x-role": "admin" } }); + assert.equal(repo.lastLimit, 25, "limit fora da faixa deve cair no default"); + await fetch(`${base}/ingestion?limit=abc`, { headers: { "x-role": "admin" } }); + assert.equal(repo.lastLimit, 25, "limit nao numerico deve cair no default"); + }); +}); diff --git a/backend/src/modules/admin/ingestion-observability.ts b/backend/src/modules/admin/ingestion-observability.ts new file mode 100644 index 0000000..a713841 --- /dev/null +++ b/backend/src/modules/admin/ingestion-observability.ts @@ -0,0 +1,107 @@ +import type { Pool } from "pg"; +import { pool } from "../../database/db.js"; + +export interface IngestionCounts { + pendente: number; + processando: number; + processado: number; + falha: number; +} + +export interface IngestionDocument { + id: string; + projeto_id: string; + projeto_nome: string; + nome: string; + extensao: string | null; + tamanho_bytes: number | null; + status_processamento: string; + processamento_tentativas: number; + processamento_erro: string | null; + processamento_proxima_tentativa: string | null; + created_at: string; + updated_at: string; +} + +export interface IngestionSnapshot { + counts: IngestionCounts; + recent: IngestionDocument[]; + active: IngestionDocument[]; + failed: IngestionDocument[]; + generated_at: string; +} + +const BASE_SELECT = ` + d.id, + d.projeto_id, + p.nome AS projeto_nome, + d.nome, + d.extensao, + d.tamanho_bytes, + d.status_processamento, + d.processamento_tentativas, + d.processamento_erro, + d.processamento_proxima_tentativa::text AS processamento_proxima_tentativa, + d.created_at::text AS created_at, + d.updated_at::text AS updated_at +`; + +/** + * Snapshot do pipeline de ingestao de documentos. Substitui o editor do n8n + * como ferramenta visual para o PO/admin acompanhar o worker da S2-01. + */ +export class IngestionObservabilityRepository { + constructor(private readonly db: Pool = pool) {} + + async snapshot(limit = 25): Promise { + const [countsResult, recent, active, failed] = await Promise.all([ + this.db.query>( + `SELECT status_processamento, COUNT(*)::int AS total + FROM documento + GROUP BY status_processamento`, + ), + this.db.query( + `SELECT ${BASE_SELECT} + FROM documento d + JOIN projeto p ON p.id = d.projeto_id + ORDER BY d.updated_at DESC + LIMIT $1`, + [limit], + ), + this.db.query( + `SELECT ${BASE_SELECT} + FROM documento d + JOIN projeto p ON p.id = d.projeto_id + WHERE d.status_processamento IN ('pendente', 'processando') + ORDER BY d.processamento_proxima_tentativa NULLS FIRST, d.created_at + LIMIT $1`, + [limit], + ), + this.db.query( + `SELECT ${BASE_SELECT} + FROM documento d + JOIN projeto p ON p.id = d.projeto_id + WHERE d.status_processamento = 'falha' + ORDER BY d.updated_at DESC + LIMIT $1`, + [limit], + ), + ]); + + const counts: IngestionCounts = { pendente: 0, processando: 0, processado: 0, falha: 0 }; + for (const row of countsResult.rows) { + const key = row.status_processamento as keyof IngestionCounts; + if (key in counts) counts[key] = Number(row.total); + } + + return { + counts, + recent: recent.rows, + active: active.rows, + failed: failed.rows, + generated_at: new Date().toISOString(), + }; + } +} + +export const ingestionObservabilityRepository = new IngestionObservabilityRepository(); diff --git a/backend/src/modules/architecture.routes.test.ts b/backend/src/modules/architecture.routes.test.ts new file mode 100644 index 0000000..65c8508 --- /dev/null +++ b/backend/src/modules/architecture.routes.test.ts @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import express, { type RequestHandler, type Router } from "express"; +import type { AddressInfo } from "node:net"; +import { createAdminRouter } from "./admin/admin.routes.js"; +import { AdminController } from "./admin/admin.controller.js"; +import { AdminService } from "./admin/admin.service.js"; +import { createDevelopersRouter } from "./developers/developers.routes.js"; +import { DevelopersController } from "./developers/developers.controller.js"; +import { DevelopersService } from "./developers/developers.service.js"; +import { errorHandler } from "../middleware/errorHandler.js"; + +const authenticate: RequestHandler = (req, res, next) => { + const role = req.headers["x-role"]; + if (role !== "admin" && role !== "po" && role !== "dev") { + res.status(401).json({ code: "UNAUTHORIZED" }); + return; + } + req.auth = { id: "user", nome: "Teste", email: "teste@example.com", role }; + next(); +}; + +async function withRouter(router: Router, run: (url: string) => Promise) { + const app = express(); + app.use(express.json(), router, errorHandler); + const server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + try { await run("http://127.0.0.1:" + (server.address() as AddressInfo).port); } + finally { await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); } +} + +const projectId = "a0000000-0000-4000-8000-000000000001"; + +test("admin mantém permissões, estatísticas e o contrato da carga sem projeto", async () => { + let calls = 0; + const inserted: string[] = []; + const counts = { projetos: 1, epicos: 2, features: 3, pbis: 4, documentos: 5, chunksIndexados: 6 }; + const service = new AdminService({ + async counts() { calls++; return counts; }, + async insertDemoChunks(id) { inserted.push(id); return 2; }, + }); + await withRouter(createAdminRouter(new AdminController(service), authenticate), async url => { + assert.equal((await fetch(url + "/stats")).status, 401); + for (const role of ["dev", "po"]) { + assert.equal((await fetch(url + "/stats", { headers: { "x-role": role } })).status, 403); + assert.equal((await fetch(url + "/ingest-seed", { method: "POST", headers: { "x-role": role } })).status, 403); + } + assert.equal(calls, 0); + const headers = { "x-role": "admin" }; + assert.deepEqual(await (await fetch(url + "/stats", { headers })).json(), { ...counts, statusSistema: "operacional" }); + const empty = await fetch(url + "/ingest-seed", { method: "POST", headers }); + assert.equal(empty.status, 400); + assert.equal((await empty.json() as { code: string }).code, "VALIDATION_ERROR"); + assert.deepEqual(inserted, []); + const seeded = await fetch(url + "/demo-seed", { method: "POST", headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify({ projeto_id: projectId }) }); + assert.equal(seeded.status, 200); + assert.equal(((await seeded.json()) as { status: string }).status, "sucesso"); + assert.deepEqual(inserted, [projectId]); + }); +}); + +// A arquitetura de busca mudou (pos-S2-06): e hibrida (vetor + full-text) e nao +// aceita mais queries sem `q` ou sem `projeto_id`. Os testes antigos deste +// arquivo foram substituidos pelos testes dedicados em search.routes.test.ts, +// search.service.test.ts, search.repository.test.ts e search.repository.db.test.ts. + +test("desenvolvedores preserva o formato e encaminha erros ao handler global", async () => { + let fail = false; + const service = new DevelopersService({ + async listDevelopers() { if (fail) throw new Error("internal database detail"); return []; }, + async listTechnologies() { return [{ id: "tech", nome: "React", categoria: "frontend" }]; }, + async listCompetencies() { return []; }, + }); + await withRouter(createDevelopersRouter(new DevelopersController(service), authenticate), async url => { + assert.equal((await fetch(url)).status, 401); + const headers = { "x-role": "dev" }; + const response = await fetch(url, { headers }); + assert.deepEqual(await response.json(), { desenvolvedores: [], tecnologias: [{ id: "tech", nome: "React", categoria: "frontend" }], competencias: [] }); + fail = true; + const error = await fetch(url, { headers }); + assert.equal(error.status, 500); + assert.doesNotMatch(await error.text(), /internal database detail/); + }); +}); diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 7a4a72d..90471c9 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -40,12 +40,12 @@ export class AuthController { return; } - if (parsed.data.role === "admin") { + if (parsed.data.role !== "dev") { const token = extractSessionToken(req); const session = token ? await this.sessions.validateSession(token) : null; if (!session?.valid || session.user.role !== "admin") { res.status(403).json({ - error: "Somente administradores podem criar contas de administrador.", + error: "Somente administradores podem criar contas de PO ou administrador.", code: "FORBIDDEN", }); return; diff --git a/backend/src/modules/auth/auth.register-role.test.ts b/backend/src/modules/auth/auth.register-role.test.ts index 9d51e32..1467e1c 100644 --- a/backend/src/modules/auth/auth.register-role.test.ts +++ b/backend/src/modules/auth/auth.register-role.test.ts @@ -74,11 +74,14 @@ test("somente uma sessão de administrador cria outra conta de administrador", a assert.deepEqual(service.registered.at(-1), { email: "admin@example.com", role: "admin" }); }); -test("cadastro público de PO, dev e do perfil padrão continua permitido", async () => { - assert.equal((await register("po")).status, 201); +test("cadastro público concede somente dev e PO exige administrador", async () => { + assert.equal((await register("po")).status, 403); + assert.equal((await register("po", "token-po")).status, 403); + assert.equal((await register("po", "token-dev")).status, 403); + assert.equal((await register("po", "token-admin")).status, 201); assert.equal((await register("dev")).status, 201); assert.equal((await register(undefined)).status, 201); - assert.deepEqual(service.registered.map((item) => item.role).slice(-3), ["po", "dev", "po"]); + assert.deepEqual(service.registered.map((item) => item.role).slice(-3), ["po", "dev", "dev"]); }); test("perfil inválido é recusado com 400", async () => { diff --git a/backend/src/modules/auth/auth.repository.ts b/backend/src/modules/auth/auth.repository.ts index 975b185..7f9f379 100644 --- a/backend/src/modules/auth/auth.repository.ts +++ b/backend/src/modules/auth/auth.repository.ts @@ -42,7 +42,7 @@ export class AuthRepository { created_at, updated_at `, - [data.nome.trim(), data.email.trim().toLowerCase(), data.senha_hash, data.role ?? "po"], + [data.nome.trim(), data.email.trim().toLowerCase(), data.senha_hash, data.role ?? "dev"], ); return result.rows[0]; @@ -199,4 +199,4 @@ export class AuthRepository { } } -export const authRepository = new AuthRepository(); \ No newline at end of file +export const authRepository = new AuthRepository(); diff --git a/backend/src/modules/auth/auth.routes.ts b/backend/src/modules/auth/auth.routes.ts index 8f2de95..ed0f206 100644 --- a/backend/src/modules/auth/auth.routes.ts +++ b/backend/src/modules/auth/auth.routes.ts @@ -37,7 +37,7 @@ export const authRouter = Router(); * role: * type: string * enum: [admin, po, dev] - * default: po + * default: dev * description: Função do usuário * responses: * 201: diff --git a/backend/src/modules/auth/auth.types.ts b/backend/src/modules/auth/auth.types.ts index 326a354..615a78c 100644 --- a/backend/src/modules/auth/auth.types.ts +++ b/backend/src/modules/auth/auth.types.ts @@ -37,7 +37,7 @@ export const registerSchema = z.object({ role: z .enum(USER_ROLES, { invalid_type_error: "Função inválida." }) .optional() - .default("po"), + .default("dev"), }); export type RegisterDTO = z.infer; diff --git a/backend/src/modules/chat/chat.fakes.ts b/backend/src/modules/chat/chat.fakes.ts index 450e014..f22b3b6 100644 --- a/backend/src/modules/chat/chat.fakes.ts +++ b/backend/src/modules/chat/chat.fakes.ts @@ -46,10 +46,15 @@ export class FakeChatRepository extends ChatRepository { return this.messages.filter((item) => item.conversa_id === conversaId); } - async addMessage(conversaId: string, remetente: "user" | "assistant", conteudo: string, fontes: ChatSource[] = []): Promise { - this.messages.push({ id: this.id("d"), conversa_id: conversaId, remetente, conteudo, fontes_json: fontes, created_at: new Date().toISOString() }); + async addMessage(conversaId: string, remetente: "user" | "assistant", conteudo: string, fontes: ChatSource[] = []): Promise { + const id = this.id("d"); + this.messages.push({ id, conversa_id: conversaId, remetente, conteudo, fontes_json: fontes, created_at: new Date().toISOString(), processing_status: remetente === "user" ? "pending" : "completed" }); + return id; } + async accessibleProjects() { return [{id:PROJECT_A,nome:"A"},{id:PROJECT_B,nome:"B"}]; } + async finishMessage(conversaId: string, messageId: string, conteudo: string, fontes: ChatSource[]) { await this.addMessage(conversaId, "assistant", conteudo, fontes); this.messages.find(m=>m.id===messageId)!.processing_status="completed"; } + async failMessage(id: string) { this.messages.find(m=>m.id===id)!.processing_status="failed"; } async searchChunks(projetoId: string | null, patterns: string[], limit: number): Promise { this.searches.push({ projetoId, patterns }); if (patterns.length === 0) return []; diff --git a/backend/src/modules/chat/chat.repository.db.test.ts b/backend/src/modules/chat/chat.repository.db.test.ts index e678ce1..d5d3f87 100644 --- a/backend/src/modules/chat/chat.repository.db.test.ts +++ b/backend/src/modules/chat/chat.repository.db.test.ts @@ -25,11 +25,25 @@ test("chat: posse da conversa e busca textual isolada por projeto no PostgreSQL" const found = await repository.searchChunks(projectA, searchPatterns("Como funciona o arquivamento?"), 5); assert.deepEqual(found.map((item) => item.id), [chunkA]); - const unscoped = await repository.searchChunks(null, searchPatterns("arquivamento"), 5); - assert.deepEqual(unscoped.map((item) => item.id).sort(), [chunkA, chunkB].sort()); + await assert.rejects(service.query(ana, { pergunta: "arquivamento" }), /Escolha um projeto/); assert.deepEqual(await repository.searchChunks(projectA, searchPatterns("inexistente"), 5), []); assert.deepEqual(await repository.searchChunks(projectA, [], 5), []); + // An unallocated developer cannot query a project, including via an old conversation. + await pool.query("UPDATE usuario SET role='dev' WHERE id=$1",[bruno]); + assert.equal(await repository.projectExists(projectA,bruno),false); + await assert.rejects(service.query(bruno,{pergunta:"arquivamento",projetoId:projectA}),NotFoundError); + const developer = (await pool.query("INSERT INTO desenvolvedor(usuario_id) VALUES($1) RETURNING id",[bruno])).rows[0].id; + await pool.query("INSERT INTO alocacao(desenvolvedor_id,projeto_id) VALUES($1,$2)",[developer,projectA]); + assert.equal(await repository.projectExists(projectA,bruno),true); + assert.equal(await repository.projectExists(projectB,bruno),false); + assert.deepEqual((await repository.accessibleProjects(bruno)).map(p=>p.id),[projectA]); + + // S2-06 trocou a busca por uma versao hibrida (vetor + full-text) com API + // propria em search.*. Esses asserts foram substituidos pelos testes + // dedicados em search.repository.db.test.ts, search.repository.test.ts + // e search.routes.test.ts. + const result = await service.query(ana, { pergunta: "Como funciona o arquivamento?", projetoId: projectA }); assert.equal(result.origem, "busca_textual"); assert.ok(!result.resposta.includes("confidencial")); @@ -38,6 +52,12 @@ test("chat: posse da conversa e busca textual isolada por projeto no PostgreSQL" await assert.rejects(service.query(bruno, { pergunta: "invasão", conversaId: result.conversa_id }), NotFoundError); const messages = await service.listMessages(ana, result.conversa_id); assert.deepEqual(messages.map((item) => item.remetente), ["user", "assistant"]); + const pending = await repository.addMessage(result.conversa_id,"user","Outra pergunta"); + await assert.rejects(repository.addMessage(result.conversa_id,"user","Concorrente"), /Aguarde/); + await assert.rejects(repository.finishMessage(result.conversa_id,pending,"Inválido\u0000",[])); + assert.equal((await repository.listMessages(result.conversa_id)).find(m=>m.id===pending)?.processing_status,"pending"); + await repository.failMessage(pending); + assert.equal((await repository.listMessages(result.conversa_id)).find(m=>m.id===pending)?.processing_status,"failed"); assert.equal((await service.listConversations(bruno)).length, 0); assert.equal((await service.listConversations(ana))[0].projeto_nome, projectA); } finally { diff --git a/backend/src/modules/chat/chat.repository.ts b/backend/src/modules/chat/chat.repository.ts index 51168b0..c123d4f 100644 --- a/backend/src/modules/chat/chat.repository.ts +++ b/backend/src/modules/chat/chat.repository.ts @@ -1,3 +1,6 @@ +import { projectAccessSql } from "../projects/project-access.js"; +import { withTransaction } from "../../database/transaction.js"; +import { ConflictError } from "../../shared/errors.js"; import { Pool } from "pg"; import { pool } from "../../database/db.js"; import type { ChatMessage, ChatSource, ChunkMatch, Conversation } from "./chat.types.js"; @@ -21,11 +24,17 @@ export class ChatRepository { return result.rows; } - async projectExists(projetoId: string): Promise { - const result = await this.pool.query("SELECT 1 FROM projeto WHERE id = $1", [projetoId]); + async projectExists(projetoId: string, usuarioId: string): Promise { + const result = await this.pool.query(`SELECT p.id FROM projeto p WHERE p.id=$1 AND ${projectAccessSql("p.id","$2")}`, [projetoId, usuarioId]); return (result.rowCount ?? 0) > 0; } + async accessibleProjects(usuarioId: string): Promise> { + const result = await this.pool.query<{ id: string; nome: string }>( + `SELECT p.id,p.nome FROM projeto p WHERE ${projectAccessSql("p.id","$1")} ORDER BY p.nome,p.id`, [usuarioId]); + return result.rows; + } + async createConversation(usuarioId: string, projetoId: string | null, titulo: string): Promise { const result = await this.pool.query( `INSERT INTO conversa (usuario_id, projeto_id, titulo) VALUES ($1, $2, $3) @@ -47,26 +56,43 @@ export class ChatRepository { async listMessages(conversaId: string): Promise { const result = await this.pool.query( - `SELECT id, conversa_id, remetente, conteudo, COALESCE(fontes_json, '[]'::jsonb) AS fontes_json, created_at + `SELECT id, conversa_id, remetente, conteudo, COALESCE(fontes_json, '[]'::jsonb) AS fontes_json, CASE WHEN processing_status='pending' AND created_at < CURRENT_TIMESTAMP - INTERVAL '2 minutes' THEN 'failed' ELSE processing_status END AS processing_status, created_at FROM mensagem WHERE conversa_id = $1 ORDER BY created_at ASC, id ASC`, [conversaId], ); return result.rows; } - async addMessage(conversaId: string, remetente: "user" | "assistant", conteudo: string, fontes: ChatSource[] = []): Promise { - await this.pool.query( - "INSERT INTO mensagem (conversa_id, remetente, conteudo, fontes_json) VALUES ($1, $2, $3, $4::jsonb)", - [conversaId, remetente, conteudo, JSON.stringify(fontes)], - ); - await this.pool.query("UPDATE conversa SET updated_at = CURRENT_TIMESTAMP WHERE id = $1", [conversaId]); + async addMessage(conversaId: string, remetente: "user" | "assistant", conteudo: string, fontes: ChatSource[] = []): Promise { + return withTransaction(this.pool, async client => { + await client.query("SELECT id FROM conversa WHERE id=$1 FOR UPDATE", [conversaId]); + await client.query("UPDATE mensagem SET processing_status='failed' WHERE conversa_id=$1 AND processing_status='pending' AND created_at < CURRENT_TIMESTAMP - INTERVAL '2 minutes'", [conversaId]); + if (remetente === "user" && (await client.query("SELECT 1 FROM mensagem WHERE conversa_id=$1 AND processing_status='pending'", [conversaId])).rowCount) throw new ConflictError("Aguarde a resposta em andamento nesta conversa."); + const result = await client.query<{ id: string }>("INSERT INTO mensagem (conversa_id,remetente,conteudo,fontes_json,processing_status) VALUES ($1,$2,$3,$4::jsonb,$5) RETURNING id", [conversaId, remetente, conteudo, JSON.stringify(fontes), remetente === "user" ? "pending" : "completed"]); + await client.query("UPDATE conversa SET updated_at=CURRENT_TIMESTAMP WHERE id=$1", [conversaId]); + return result.rows[0].id; + }); + } + + async finishMessage(conversaId: string, messageId: string, conteudo: string, fontes: ChatSource[]): Promise { + await withTransaction(this.pool, async client => { + await client.query("SELECT id FROM conversa WHERE id=$1 FOR UPDATE", [conversaId]); + const result = await client.query("UPDATE mensagem SET processing_status='completed' WHERE id=$1 AND conversa_id=$2 AND processing_status='pending' RETURNING id", [messageId, conversaId]); + if (!result.rowCount) throw new ConflictError("A pergunta não está mais em processamento."); + await client.query("INSERT INTO mensagem(conversa_id,remetente,conteudo,fontes_json) VALUES ($1,'assistant',$2,$3::jsonb)", [conversaId, conteudo, JSON.stringify(fontes)]); + await client.query("UPDATE conversa SET updated_at=CURRENT_TIMESTAMP WHERE id=$1", [conversaId]); + }); + } + + async failMessage(messageId: string): Promise { + await this.pool.query("UPDATE mensagem SET processing_status='failed' WHERE id=$1 AND processing_status='pending'", [messageId]); } - async searchChunks(projetoId: string | null, patterns: string[], limit: number): Promise { + async searchChunks(projetoId: string, patterns: string[], limit: number): Promise { if (patterns.length === 0) return []; const result = await this.pool.query( `SELECT id, entidade_tipo, entidade_id, texto FROM chunk - WHERE ($1::uuid IS NULL OR projeto_id = $1) AND texto ILIKE ANY($2::text[]) + WHERE projeto_id = $1 AND texto ILIKE ANY($2::text[]) ORDER BY created_at DESC, id LIMIT $3`, [projetoId, patterns, limit], ); diff --git a/backend/src/modules/chat/chat.routes.ts b/backend/src/modules/chat/chat.routes.ts index 2415646..3e2b9ce 100644 --- a/backend/src/modules/chat/chat.routes.ts +++ b/backend/src/modules/chat/chat.routes.ts @@ -16,6 +16,8 @@ export function createChatRouter(service: ChatService = new ChatService(), authe const router = Router(); router.use(authentication); + router.get("/projects", async (req, res, next) => { try { res.json({ items: await service.accessibleProjects(userId(req)) }); } catch (error) { next(error); } }); + router.get("/conversations", async (req: Request, res: Response, next: NextFunction) => { try { const items = await service.listConversations(userId(req)); diff --git a/backend/src/modules/chat/chat.service.test.ts b/backend/src/modules/chat/chat.service.test.ts index 446f960..ae7ca2e 100644 --- a/backend/src/modules/chat/chat.service.test.ts +++ b/backend/src/modules/chat/chat.service.test.ts @@ -10,6 +10,24 @@ function setup() { return { repository, assistant, service: new ChatService(repository, assistant) }; } +test("recusa consultas globais e troca de projeto antes de gravar mensagens", async () => { + const { service, repository, assistant } = setup(); + await assert.rejects(service.query(ANA,{pergunta:"Consultar decisões"}),ValidationError); + const conversation = await repository.createConversation(ANA,PROJECT_A,"A"); + await assert.rejects(service.query(ANA,{pergunta:"Consultar decisões",conversaId:conversation.id,projetoId:PROJECT_B}),ValidationError); + assert.equal(repository.messages.length,0); + assert.equal(assistant.calls.length,0); +}); + +test("falha do processamento fica registrada no histórico", async () => { + const { service, repository, assistant } = setup(); + assistant.available=false; + repository.searchChunks=async () => { throw new Error("banco indisponível"); }; + await assert.rejects(service.query(ANA,{pergunta:"Consultar decisões",projetoId:PROJECT_A}), /Não foi possível processar/); + assert.equal(repository.messages.length,1); + assert.equal(repository.messages[0].processing_status,"failed"); +}); + test("nova pergunta cria a conversa, registra as duas mensagens e devolve as fontes do assistente", async () => { const { service, repository, assistant } = setup(); const result = await service.query(ANA, { pergunta: " Quais decisões definem o login? ", projetoId: PROJECT_A }); @@ -99,7 +117,7 @@ test("fontes do assistente são saneadas e limitadas", () => { test("cria conversa com título padrão e valida o projeto", async () => { const { service } = setup(); - assert.equal((await service.createConversation(ANA, {})).titulo, "Nova conversa"); + assert.equal((await service.createConversation(ANA, { projetoId: PROJECT_A })).titulo, "Nova conversa"); assert.equal((await service.createConversation(ANA, { titulo: " Reunião ", projetoId: PROJECT_A })).titulo, "Reunião"); await assert.rejects(service.createConversation(ANA, { projetoId: "x" }), ValidationError); assert.equal((await service.listConversations(BRUNO)).length, 0); diff --git a/backend/src/modules/chat/chat.service.ts b/backend/src/modules/chat/chat.service.ts index 7f16993..0621b14 100644 --- a/backend/src/modules/chat/chat.service.ts +++ b/backend/src/modules/chat/chat.service.ts @@ -1,6 +1,7 @@ +import { serviceHeaders } from "../../shared/service-auth.js"; import axios from "axios"; import { env } from "../../config/env.js"; -import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; +import { AppError, NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; import { ChatRepository } from "./chat.repository.js"; import { MAX_QUESTION_LENGTH, @@ -32,16 +33,26 @@ export function sanitizeSources(value: unknown): ChatSource[] { } export class HttpAssistantClient implements AssistantClient { - constructor(private readonly baseUrl: string = env.AI_SERVICE_URL) {} + constructor(private readonly baseUrl: string = env.AI_SERVICE_URL, private readonly repository = new ChatRepository()) { } async ask(pergunta: string, projetoId?: string): Promise { - const response = await axios.post(`${this.baseUrl}/rag/query`, { pergunta, projeto_id: projetoId }, { timeout: 5000 }); - const text = response.data?.resposta ?? response.data?.conteudo; + if (!projetoId) throw new ValidationError("Escolha um projeto."); + const chunks = await this.repository.searchChunks(projetoId, searchPatterns(pergunta), 5); + if (!chunks.length) return { resposta: NOT_FOUND_ANSWER, fontes: [] }; + const response = await axios.post(this.baseUrl + "/rag/query", { query: pergunta, project_id: projetoId, context_chunks: chunks.map(c => "[" + c.id + "] " + c.texto) }, { headers: serviceHeaders(), timeout: 45000 }); + const text = response.data?.response; if (typeof text !== "string" || !text.trim()) throw new Error("Resposta vazia do assistente"); - return { resposta: text, fontes: sanitizeSources(response.data?.fontes) }; + return { resposta: text, fontes: citedSources(text, chunks) }; } } +export function citedSources(text: string, chunks: Array<{id: string; entidade_tipo: string}>): ChatSource[] { + const ids = [...text.matchAll(/\[([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\]/gi)].map(match => match[1].toLowerCase()); + const byId = new Map(chunks.map(chunk => [chunk.id.toLowerCase(), chunk])); + if (!ids.length || ids.some(id => !byId.has(id))) throw new Error("Resposta sem citações válidas"); + return [...new Set(ids)].map(id => ({ id, titulo: "Trecho citado de " + byId.get(id)!.entidade_tipo, tipo: byId.get(id)!.entidade_tipo })); +} + export function searchPatterns(question: string): string[] { const terms = question .toLocaleLowerCase("pt-BR") @@ -58,22 +69,26 @@ function makeTitle(question: string): string { export class ChatService { constructor( private readonly repository: ChatRepository = new ChatRepository(), - private readonly assistant: AssistantClient = new HttpAssistantClient(), - ) {} + private readonly assistant: AssistantClient = new HttpAssistantClient(env.AI_SERVICE_URL, repository), + ) { } - listConversations(usuarioId: string): Promise { - return this.repository.listConversations(usuarioId); + accessibleProjects(usuarioId: string) { return this.repository.accessibleProjects(usuarioId); } + + async listConversations(usuarioId: string): Promise { + const items = await this.repository.listConversations(usuarioId); + const permitted = new Set((await this.repository.accessibleProjects(usuarioId)).map(p => p.id)); + return items.filter(c => c.projeto_id !== null && permitted.has(c.projeto_id)); } - private async requireProject(projetoId: string | undefined): Promise { - if (!projetoId) return null; + private async requireProject(projetoId: string | undefined, usuarioId: string): Promise { + if (!projetoId) throw new ValidationError("Escolha um projeto para iniciar a conversa."); validateUuid(projetoId, "ID do projeto"); - if (!(await this.repository.projectExists(projetoId))) throw new NotFoundError("Projeto não encontrado."); + if (!(await this.repository.projectExists(projetoId, usuarioId))) throw new NotFoundError("Projeto não encontrado."); return projetoId; } async createConversation(usuarioId: string, input: { titulo?: unknown; projetoId?: string }): Promise { - const projetoId = await this.requireProject(input.projetoId); + const projetoId = await this.requireProject(input.projetoId, usuarioId); const titulo = typeof input.titulo === "string" && input.titulo.trim() ? input.titulo.trim().slice(0, 120) : "Nova conversa"; return this.repository.createConversation(usuarioId, projetoId, titulo); } @@ -82,6 +97,7 @@ export class ChatService { validateUuid(conversaId, "ID da conversa"); const conversation = await this.repository.findOwnedConversation(conversaId, usuarioId); if (!conversation) throw new NotFoundError("Conversa não encontrada."); + await this.requireProject(conversation.projeto_id ?? undefined, usuarioId); return this.repository.listMessages(conversaId); } @@ -90,39 +106,46 @@ export class ChatService { if (!pergunta) throw new ValidationError("Digite uma pergunta."); if (pergunta.length > MAX_QUESTION_LENGTH) throw new ValidationError(`A pergunta pode ter no máximo ${MAX_QUESTION_LENGTH} caracteres.`); - let projetoId = await this.requireProject(input.projetoId); + let projetoId: string; let conversaId = input.conversaId; if (conversaId) { validateUuid(conversaId, "ID da conversa"); const conversation = await this.repository.findOwnedConversation(conversaId, usuarioId); if (!conversation) throw new NotFoundError("Conversa não encontrada."); - projetoId = projetoId ?? conversation.projeto_id; + if (input.projetoId && input.projetoId !== conversation.projeto_id) throw new ValidationError("O projeto da conversa não pode ser alterado."); + projetoId = await this.requireProject(conversation.projeto_id ?? undefined, usuarioId); } else { + projetoId = await this.requireProject(input.projetoId, usuarioId); conversaId = (await this.repository.createConversation(usuarioId, projetoId, makeTitle(pergunta))).id; } - await this.repository.addMessage(conversaId, "user", pergunta); - - let answer: AssistantAnswer; - let origem: QueryResult["origem"]; + const messageId = await this.repository.addMessage(conversaId, "user", pergunta); try { - answer = await this.assistant.ask(pergunta, projetoId ?? undefined); - origem = "assistente"; - } catch { - const chunks = await this.repository.searchChunks(projetoId, searchPatterns(pergunta), 3); - if (chunks.length > 0) { - answer = { - resposta: `O assistente está indisponível. Estes trechos do acervo mencionam termos da sua pergunta:\n\n${chunks.map((chunk) => `- ${chunk.texto}`).join("\n\n")}`, - fontes: chunks.map((chunk) => ({ id: chunk.id, titulo: `Trecho de ${chunk.entidade_tipo}`, tipo: chunk.entidade_tipo })), - }; - origem = "busca_textual"; - } else { - answer = { resposta: NOT_FOUND_ANSWER, fontes: [] }; - origem = "sem_resultado"; + + let answer: AssistantAnswer; + let origem: QueryResult["origem"]; + try { + answer = await this.assistant.ask(pergunta, projetoId ?? undefined); + origem = answer.resposta === NOT_FOUND_ANSWER ? "sem_resultado" : "assistente"; + } catch { + const chunks = await this.repository.searchChunks(projetoId, searchPatterns(pergunta), 3); + if (chunks.length > 0) { + answer = { + resposta: `O assistente está indisponível. Estes trechos do acervo mencionam termos da sua pergunta:\n\n${chunks.map((chunk) => `- ${chunk.texto}`).join("\n\n")}`, + fontes: chunks.map((chunk) => ({ id: chunk.id, titulo: `Trecho de ${chunk.entidade_tipo}`, tipo: chunk.entidade_tipo })), + }; + origem = "busca_textual"; + } else { + answer = { resposta: NOT_FOUND_ANSWER, fontes: [] }; + origem = "sem_resultado"; + } } - } - await this.repository.addMessage(conversaId, "assistant", answer.resposta, answer.fontes); - return { conversa_id: conversaId, resposta: answer.resposta, fontes: answer.fontes, origem }; + await this.repository.finishMessage(conversaId, messageId, answer.resposta, answer.fontes); + return { conversa_id: conversaId, resposta: answer.resposta, fontes: answer.fontes, origem }; + } catch (error) { + await this.repository.failMessage(messageId).catch(() => undefined); + throw new AppError("Não foi possível processar a pergunta. Tente novamente.", 503, "CHAT_FAILED", { conversa_id: conversaId, mensagem_id: messageId }); + } } } diff --git a/backend/src/modules/chat/chat.sources.test.ts b/backend/src/modules/chat/chat.sources.test.ts new file mode 100644 index 0000000..7a0b77f --- /dev/null +++ b/backend/src/modules/chat/chat.sources.test.ts @@ -0,0 +1,10 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { citedSources } from "./chat.service.js"; +const a="10000000-0000-4000-8000-000000000001", b="10000000-0000-4000-8000-000000000002"; +test("fontes incluem apenas IDs citados presentes no contexto",()=>{ + const chunks=[{id:a,entidade_tipo:"documento"},{id:b,entidade_tipo:"pbi"}]; + assert.deepEqual(citedSources('Resposta ['+a+'] ['+a+']',chunks).map(s=>s.id),[a]); + assert.throws(()=>citedSources("Resposta sem referência",chunks)); + assert.throws(()=>citedSources('Resposta ['+b+']',chunks.slice(0,1))); +}); diff --git a/backend/src/modules/chat/chat.types.ts b/backend/src/modules/chat/chat.types.ts index 8720581..658d1e5 100644 --- a/backend/src/modules/chat/chat.types.ts +++ b/backend/src/modules/chat/chat.types.ts @@ -22,6 +22,7 @@ export interface ChatMessage { remetente: "user" | "assistant" | "system"; conteudo: string; fontes_json: ChatSource[]; + processing_status?: "pending" | "completed" | "failed"; created_at: string; } diff --git a/backend/src/modules/criteria/criteria.repository.ts b/backend/src/modules/criteria/criteria.repository.ts index 7187a91..6d89bac 100644 --- a/backend/src/modules/criteria/criteria.repository.ts +++ b/backend/src/modules/criteria/criteria.repository.ts @@ -1,13 +1,14 @@ -import { lockHierarchy, assertWritable } from "../projects/hierarchy-archive.js"; import { Pool, PoolClient } from "pg"; import { pool } from "../../database/db.js"; -import { CreateCriterionDTO, Criterion, CriterionEntityType } from "./criteria.types.js"; -import { auditService } from "../audit/audit.service.js"; +import { withTransaction } from "../../database/transaction.js"; import { ValidationError } from "../../shared/errors.js"; +import { auditService } from "../audit/audit.service.js"; +import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; import { assertJustificationForCompletedItem, normalizeJustification, } from "../quality/completed-item-policy.js"; +import { CreateCriterionDTO, Criterion, CriterionEntityType } from "./criteria.types.js"; const ENTITY_TABLE: Record = { epico: "epico", @@ -100,6 +101,7 @@ export class CriteriaRepository { // entre movimentos concorrentes em direções opostas (dois SELECT ... FOR UPDATE em ordens // diferentes causariam espera circular). O lock é liberado automaticamente no COMMIT/ROLLBACK. private async lockEntity(client: PoolClient, tipo: CriterionEntityType, entidadeId: string): Promise { + await lockHierarchy(client, tipo, entidadeId); await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [`${tipo}:${entidadeId}`]); await assertWritable(client, tipo, entidadeId); } @@ -109,8 +111,8 @@ export class CriteriaRepository { return result.rows[0] ?? null; } - async listByEntity(tipo: CriterionEntityType, entidadeId: string): Promise { - const result = await this.pool.query( + async listByEntity(tipo: CriterionEntityType, entidadeId: string, executor: Pool | PoolClient = this.pool): Promise { + const result = await executor.query( `SELECT * FROM criterio_aceitacao WHERE entidade_tipo = $1 AND entidade_id = $2 ORDER BY ordem ASC`, [tipo, entidadeId], ); @@ -135,11 +137,7 @@ export class CriteriaRepository { } async create(dto: CreateCriterionDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); - - try { - await client.query("BEGIN"); - await lockHierarchy(client); + return withTransaction(this.pool, async (client) => { await this.lockEntity(client, dto.entidade_tipo, dto.entidade_id); await assertJustificationForCompletedItem( client, @@ -186,14 +184,9 @@ export class CriteriaRepository { client, ); - await client.query("COMMIT"); return created; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + + }); } async delete( @@ -201,15 +194,11 @@ export class CriteriaRepository { usuarioId?: string | null, justificativa?: string | null, ): Promise { - const client: PoolClient = await this.pool.connect(); - - try { - await client.query("BEGIN"); - await lockHierarchy(client); + return withTransaction(this.pool, async (client) => { const peek = await client.query(`SELECT entidade_tipo, entidade_id FROM criterio_aceitacao WHERE id = $1`, [id]); if (!peek.rows[0]) { - await client.query("ROLLBACK"); + return null; } await this.lockEntity(client, peek.rows[0].entidade_tipo, peek.rows[0].entidade_id); @@ -217,7 +206,7 @@ export class CriteriaRepository { const existing = await client.query(`SELECT * FROM criterio_aceitacao WHERE id = $1`, [id]); const removed = existing.rows[0]; if (!removed) { - await client.query("ROLLBACK"); + return null; } @@ -255,14 +244,9 @@ export class CriteriaRepository { client, ); - await client.query("COMMIT"); return removed; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + + }); } async move( @@ -271,15 +255,11 @@ export class CriteriaRepository { usuarioId?: string | null, justificativa?: string | null, ): Promise { - const client: PoolClient = await this.pool.connect(); - - try { - await client.query("BEGIN"); - await lockHierarchy(client); + return withTransaction(this.pool, async (client) => { const peek = await client.query(`SELECT entidade_tipo, entidade_id FROM criterio_aceitacao WHERE id = $1`, [id]); if (!peek.rows[0]) { - await client.query("ROLLBACK"); + return null; } await this.lockEntity(client, peek.rows[0].entidade_tipo, peek.rows[0].entidade_id); @@ -287,7 +267,7 @@ export class CriteriaRepository { const currentResult = await client.query(`SELECT * FROM criterio_aceitacao WHERE id = $1`, [id]); const current = currentResult.rows[0]; if (!current) { - await client.query("ROLLBACK"); + return null; } @@ -306,7 +286,7 @@ export class CriteriaRepository { `SELECT * FROM criterio_aceitacao WHERE entidade_tipo = $1 AND entidade_id = $2 ORDER BY ordem ASC`, [current.entidade_tipo, current.entidade_id], ); - await client.query("COMMIT"); + return listaSemAlteracao.rows; } @@ -339,14 +319,9 @@ export class CriteriaRepository { [current.entidade_tipo, current.entidade_id], ); - await client.query("COMMIT"); return listaAtualizada.rows; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + + }); } } diff --git a/backend/src/modules/decisions/decisions.db.test.ts b/backend/src/modules/decisions/decisions.db.test.ts index b9cfc0f..ecc3bdd 100644 --- a/backend/src/modules/decisions/decisions.db.test.ts +++ b/backend/src/modules/decisions/decisions.db.test.ts @@ -63,7 +63,7 @@ test("S1-18: decisões em qualquer nível, herança, isolamento e arquivamento n const archiver = await pool.connect(); try { await archiver.query("BEGIN"); - await lockHierarchy(archiver); + await lockHierarchy(archiver, "projeto", archivedProject); await archiver.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [archivedProject]); const attempt = service.create("projeto", archivedProject, user, body("Corrida")); await new Promise((resolve) => setTimeout(resolve, 150)); diff --git a/backend/src/modules/decisions/decisions.repository.ts b/backend/src/modules/decisions/decisions.repository.ts index 140507d..11dcbcf 100644 --- a/backend/src/modules/decisions/decisions.repository.ts +++ b/backend/src/modules/decisions/decisions.repository.ts @@ -68,7 +68,7 @@ export class DecisionsRepository { const client = await this.pool.connect(); try { await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, input.entidadeTipo, input.entidadeId); await assertWritable(client, input.entidadeTipo, input.entidadeId); const inserted = await client.query<{ id: string }>( `INSERT INTO decisao (entidade_tipo, entidade_id, titulo, contexto, decisao, justificativa, alternativas, autor_id) diff --git a/backend/src/modules/developers/developers.controller.ts b/backend/src/modules/developers/developers.controller.ts new file mode 100644 index 0000000..ac0f189 --- /dev/null +++ b/backend/src/modules/developers/developers.controller.ts @@ -0,0 +1,14 @@ +import type { NextFunction, Request, Response } from "express"; +import { DevelopersService } from "./developers.service.js"; + +export class DevelopersController { + constructor(private readonly service: DevelopersService = new DevelopersService()) {} + + list = async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + res.json(await this.service.overview()); + } catch (error) { + next(error); + } + }; +} diff --git a/backend/src/modules/developers/developers.repository.ts b/backend/src/modules/developers/developers.repository.ts new file mode 100644 index 0000000..3fa82da --- /dev/null +++ b/backend/src/modules/developers/developers.repository.ts @@ -0,0 +1,28 @@ +import type { Developer, DeveloperTechnology, Competency } from "./developers.types.js"; +import type { Pool } from "pg"; +import { pool } from "../../database/db.js"; + +export class DevelopersRepository { + constructor(private readonly db: Pick = pool) {} + + async listDevelopers(): Promise { + const { rows } = await this.db.query( + `SELECT d.id, d.senioridade, d.bio, u.nome, u.email, u.role + FROM desenvolvedor d JOIN usuario u ON d.usuario_id = u.id ORDER BY u.nome ASC`, + ); + return rows; + } + + async listTechnologies(): Promise { + const { rows } = await this.db.query("SELECT id, nome, categoria FROM tecnologia ORDER BY nome ASC"); + return rows; + } + + async listCompetencies(): Promise { + const { rows } = await this.db.query( + `SELECT c.id, c.desenvolvedor_id, c.tecnologia_id, c.nivel, c.evidencia, t.nome AS tecnologia_nome + FROM competencia c JOIN tecnologia t ON c.tecnologia_id = t.id`, + ); + return rows; + } +} diff --git a/backend/src/modules/developers/developers.routes.ts b/backend/src/modules/developers/developers.routes.ts index 68a069c..645ca49 100644 --- a/backend/src/modules/developers/developers.routes.ts +++ b/backend/src/modules/developers/developers.routes.ts @@ -1,35 +1,12 @@ -import { Router, Request, Response, NextFunction } from "express"; -import { pool } from "../../database/db.js"; +import { Router, type RequestHandler } from "express"; import { requireAuth } from "../../middleware/requireAuth.js"; +import { DevelopersController } from "./developers.controller.js"; -export const developersRouter = Router(); +export function createDevelopersRouter(controller = new DevelopersController(), authenticate: RequestHandler = requireAuth) { + const router = Router(); + router.use(authenticate); + router.get("/", controller.list); + return router; +} -developersRouter.use(requireAuth); - -// GET /api/v1/developers - Mapeamento de Desenvolvedores e Competências -developersRouter.get("/", async (_req: Request, res: Response, next: NextFunction) => { - try { - const devsRes = await pool.query(` - SELECT d.id, d.senioridade, d.bio, u.nome, u.email, u.role - FROM desenvolvedor d - JOIN usuario u ON d.usuario_id = u.id - ORDER BY u.nome ASC - `); - - const techRes = await pool.query("SELECT id, nome, categoria FROM tecnologia ORDER BY nome ASC"); - - const compsRes = await pool.query(` - SELECT c.id, c.desenvolvedor_id, c.tecnologia_id, c.nivel, c.evidencia, t.nome as tecnologia_nome - FROM competencia c - JOIN tecnologia t ON c.tecnologia_id = t.id - `); - - res.json({ - desenvolvedores: devsRes.rows, - tecnologias: techRes.rows, - competencias: compsRes.rows, - }); - } catch (error) { - next(error); - } -}); +export const developersRouter = createDevelopersRouter(); diff --git a/backend/src/modules/developers/developers.service.ts b/backend/src/modules/developers/developers.service.ts new file mode 100644 index 0000000..a0b6e98 --- /dev/null +++ b/backend/src/modules/developers/developers.service.ts @@ -0,0 +1,15 @@ +import type { DevelopersOverview } from "./developers.types.js"; +import { DevelopersRepository } from "./developers.repository.js"; + +export class DevelopersService { + constructor(private readonly repository: Pick = new DevelopersRepository()) {} + + async overview(): Promise { + const [desenvolvedores, tecnologias, competencias] = await Promise.all([ + this.repository.listDevelopers(), + this.repository.listTechnologies(), + this.repository.listCompetencies(), + ]); + return { desenvolvedores, tecnologias, competencias }; + } +} diff --git a/backend/src/modules/developers/developers.types.ts b/backend/src/modules/developers/developers.types.ts new file mode 100644 index 0000000..180906c --- /dev/null +++ b/backend/src/modules/developers/developers.types.ts @@ -0,0 +1,31 @@ +import type { UserRole } from "../auth/auth.types.js"; + +export interface Developer { + id: string; + senioridade: string | null; + bio: string | null; + nome: string; + email: string; + role: UserRole; +} + +export interface DeveloperTechnology { + id: string; + nome: string; + categoria: string | null; +} + +export interface Competency { + id: string; + desenvolvedor_id: string; + tecnologia_id: string; + nivel: string | null; + evidencia: string | null; + tecnologia_nome: string; +} + +export interface DevelopersOverview { + desenvolvedores: Developer[]; + tecnologias: DeveloperTechnology[]; + competencias: Competency[]; +} diff --git a/backend/src/modules/documents/chunks.routes.test.ts b/backend/src/modules/documents/chunks.routes.test.ts new file mode 100644 index 0000000..7916a27 --- /dev/null +++ b/backend/src/modules/documents/chunks.routes.test.ts @@ -0,0 +1,78 @@ +import test, { after, before } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { AddressInfo } from "node:net"; +import { Server } from "node:http"; +import { createChunksRouter } from "./chunks.routes.js"; +import { ChunksService, type PersistChunksInput, type PersistChunksResult } from "./chunks.service.js"; +import { errorHandler } from "../../middleware/errorHandler.js"; +import { env } from "../../config/env.js"; + +const PROJECT_ID = "a0000000-0000-4000-8000-000000000001"; +const DOCUMENT_ID = "b0000000-0000-4000-8000-000000000001"; + +class StubChunksService extends ChunksService { + public calls: PersistChunksInput[] = []; + async persist(input: PersistChunksInput): Promise { + this.calls.push(input); + return { + documento_id: input.documentoId, + projeto_id: input.projetoId, + total_chunks: input.chunks.length, + status_processamento: "processado", + }; + } +} + +const service = new StubChunksService(); +let server: Server; +let baseUrl: string; + +before(() => { + const app = express(); + app.use(express.json()); + app.use("/api/v1/projects/:projectId/documents/:documentId/chunks", createChunksRouter(service)); + app.use(errorHandler); + server = app.listen(0); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1/projects/${PROJECT_ID}/documents/${DOCUMENT_ID}/chunks`; +}); + +after(() => { + server.close(); +}); + +const validEmbedding = () => Array.from({ length: 1024 }, () => 0.1); +const call = (init: { token?: string | null; body?: unknown } = {}) => + fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(init.token === null ? {} : { Authorization: `Bearer ${init.token ?? env.N8N_INGEST_TOKEN}` }), + }, + body: init.body === undefined ? JSON.stringify({ chunks: [{ chunk_index: 0, content: "x", embedding: validEmbedding() }] }) : JSON.stringify(init.body), + }); + +test("sem Authorization retorna 401 sem chamar o serviço", async () => { + service.calls = []; + const res = await call({ token: null }); + assert.equal(res.status, 401); + assert.equal(service.calls.length, 0); +}); + +test("token errado retorna 401", async () => { + service.calls = []; + assert.equal((await call({ token: "errado" })).status, 401); + assert.equal(service.calls.length, 0); +}); + +test("payload válido passa pelo serviço e devolve o resumo", async () => { + service.calls = []; + const res = await call(); + assert.equal(res.status, 200); + const body = await res.json() as { total_chunks: number; status_processamento: string }; + assert.equal(body.total_chunks, 1); + assert.equal(body.status_processamento, "processado"); + assert.equal(service.calls.length, 1); + assert.equal(service.calls[0].projetoId, PROJECT_ID); + assert.equal(service.calls[0].documentoId, DOCUMENT_ID); +}); diff --git a/backend/src/modules/documents/chunks.routes.ts b/backend/src/modules/documents/chunks.routes.ts new file mode 100644 index 0000000..25f355f --- /dev/null +++ b/backend/src/modules/documents/chunks.routes.ts @@ -0,0 +1,73 @@ +import { Router, type NextFunction, type Request, type Response } from "express"; +import { env } from "../../config/env.js"; +import { ValidationError } from "../../shared/errors.js"; +import { chunksService, type ChunksService } from "./chunks.service.js"; + +function paramOf(value: string | string[] | undefined): string { + if (Array.isArray(value)) return value[0] ?? ""; + return value ?? ""; +} + +/** + * Middleware server-to-server para o pipeline n8n. Confere o token compartilhado + * em Authorization: Bearer ; mantido separado do fluxo de + * usuário humano (requireAuth) porque o n8n não tem sessão. + */ +export function requireIngestToken(req: Request, res: Response, next: NextFunction): void { + const header = req.headers.authorization ?? ""; + const [scheme, token] = header.split(" ", 2); + if (scheme !== "Bearer" || !token || token !== env.N8N_INGEST_TOKEN) { + res.status(401).json({ error: "Token de ingestão ausente ou inválido.", code: "INGEST_TOKEN_INVALID" }); + return; + } + next(); +} + +export function createChunksRouter(service: ChunksService = chunksService): Router { + const router = Router({ mergeParams: true }); + + /** + * @swagger + * /api/v1/projects/{projectId}/documents/{documentId}/chunks: + * post: + * summary: Persiste chunks com embeddings (pipeline n8n -> backend) + * description: > + * Chamado pelo workflow do n8n após extrair texto, chunking e embeddings. + * Substitui qualquer chunk anterior do mesmo documento (reindexação + * idempotente) e marca o documento como 'processado'. Autenticação por + * token compartilhado em Authorization: Bearer . + * tags: [Documents] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Chunks persistidos + * 400: + * description: Payload inválido (chunks ausentes, embedding fora do padrão bge-m3) + * 401: + * description: Token de ingestão ausente ou inválido + * 404: + * description: Projeto ou documento não encontrados + * 409: + * description: Projeto arquivado + */ + router.post("/", requireIngestToken, async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.body || typeof req.body !== "object") { + throw new ValidationError("Corpo da requisição deve ser JSON com { chunks: [...] }."); + } + const result = await service.persist({ + projetoId: paramOf(req.params.projectId), + documentoId: paramOf(req.params.documentId), + chunks: (req.body as { chunks?: unknown }).chunks as never, + }); + res.status(200).json(result); + } catch (error) { + next(error); + } + }); + + return router; +} + +export const chunksRouter = createChunksRouter(); diff --git a/backend/src/modules/documents/chunks.service.test.ts b/backend/src/modules/documents/chunks.service.test.ts new file mode 100644 index 0000000..5ac2bda --- /dev/null +++ b/backend/src/modules/documents/chunks.service.test.ts @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ChunksService } from "./chunks.service.js"; +import { ValidationError } from "../../shared/errors.js"; + +const service = new ChunksService(); + +const PROJECT_ID = "a0000000-0000-4000-8000-000000000001"; +const DOCUMENT_ID = "b0000000-0000-4000-8000-000000000001"; +const embedding1024 = Array.from({ length: 1024 }, () => 0.1); + +test("recusa projetoId não-UUID antes de qualquer trabalho", async () => { + await assert.rejects( + service.persist({ projetoId: "nao-uuid", documentoId: DOCUMENT_ID, chunks: [{ chunk_index: 0, content: "x", embedding: embedding1024 }] }), + ValidationError, + ); +}); + +test("recusa lista vazia de chunks", async () => { + await assert.rejects( + service.persist({ projetoId: PROJECT_ID, documentoId: DOCUMENT_ID, chunks: [] }), + ValidationError, + ); +}); + +test("recusa embedding fora de 1024 dimensões (bge-m3)", async () => { + await assert.rejects( + service.persist({ projetoId: PROJECT_ID, documentoId: DOCUMENT_ID, chunks: [{ chunk_index: 0, content: "x", embedding: [0.1, 0.2] }] }), + /1024 dimensões/, + ); +}); + +test("recusa chunk sem texto", async () => { + await assert.rejects( + service.persist({ projetoId: PROJECT_ID, documentoId: DOCUMENT_ID, chunks: [{ chunk_index: 0, content: " ", embedding: embedding1024 }] }), + /sem texto/, + ); +}); + +test("recusa embedding com valor não numérico", async () => { + const bad = [...embedding1024]; + bad[500] = Number.NaN; + await assert.rejects( + service.persist({ projetoId: PROJECT_ID, documentoId: DOCUMENT_ID, chunks: [{ chunk_index: 0, content: "x", embedding: bad }] }), + /não numérico/, + ); +}); diff --git a/backend/src/modules/documents/chunks.service.ts b/backend/src/modules/documents/chunks.service.ts new file mode 100644 index 0000000..d4c6030 --- /dev/null +++ b/backend/src/modules/documents/chunks.service.ts @@ -0,0 +1,150 @@ +import type { PoolClient } from "pg"; +import { pool } from "../../database/db.js"; +import { AppError, NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; +import { ArchiveConflict } from "../projects/archive.types.js"; +import { lockHierarchy } from "../projects/hierarchy-archive.js"; +import { auditService } from "../audit/audit.service.js"; + +export interface IncomingChunk { + chunk_index: number; + content: string; + metadata?: Record; + embedding: number[]; +} + +export interface PersistChunksInput { + projetoId: string; + documentoId: string; + chunks: IncomingChunk[]; +} + +export interface PersistChunksResult { + documento_id: string; + projeto_id: string; + total_chunks: number; + status_processamento: "processado"; +} + +const EXPECTED_DIMENSION = 1024; +const MAX_CHUNKS = 2000; + +function assertChunk(chunk: unknown, position: number): asserts chunk is IncomingChunk { + if (!chunk || typeof chunk !== "object") { + throw new ValidationError(`Chunk ${position} inválido.`); + } + const candidate = chunk as Partial; + if (typeof candidate.chunk_index !== "number" || !Number.isInteger(candidate.chunk_index) || candidate.chunk_index < 0) { + throw new ValidationError(`Chunk ${position} com chunk_index inválido.`); + } + if (typeof candidate.content !== "string" || candidate.content.trim().length === 0) { + throw new ValidationError(`Chunk ${position} sem texto.`); + } + if (!Array.isArray(candidate.embedding) || candidate.embedding.length !== EXPECTED_DIMENSION) { + throw new ValidationError(`Chunk ${position} com embedding fora do padrão bge-m3 (${EXPECTED_DIMENSION} dimensões).`); + } + for (const value of candidate.embedding) { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new ValidationError(`Chunk ${position} tem valor não numérico no embedding.`); + } + } +} + +function toVectorLiteral(embedding: number[]): string { + return `[${embedding.join(",")}]`; +} + +export class ChunksService { + async persist(input: PersistChunksInput): Promise { + validateUuid(input.projetoId, "ID do projeto"); + validateUuid(input.documentoId, "ID do documento"); + if (!Array.isArray(input.chunks) || input.chunks.length === 0) { + throw new ValidationError("Envie ao menos um chunk."); + } + if (input.chunks.length > MAX_CHUNKS) { + throw new ValidationError(`Lote excede o limite de ${MAX_CHUNKS} chunks.`); + } + input.chunks.forEach(assertChunk); + + const client: PoolClient = await pool.connect(); + try { + await client.query("BEGIN"); + // S2-01 mudou a assinatura: lockHierarchy agora trava o projeto dono. + // Para a tabela chunk (ligada a 'documento' -> 'projeto'), bloqueamos o projeto. + await lockHierarchy(client, "projeto", input.projetoId); + + const projectRow = await client.query<{ status: string }>( + "SELECT status FROM projeto WHERE id = $1", + [input.projetoId], + ); + if (projectRow.rowCount === 0) { + throw new NotFoundError("Projeto não encontrado."); + } + if (projectRow.rows[0].status === "arquivado") { + throw new ArchiveConflict("Projeto arquivado é somente leitura e não recebe novos chunks."); + } + + const document = await client.query<{ id: string; projeto_id: string; status_processamento: string }>( + "SELECT id, projeto_id, status_processamento FROM documento WHERE id = $1 AND projeto_id = $2 FOR UPDATE", + [input.documentoId, input.projetoId], + ); + if (document.rowCount === 0) { + throw new NotFoundError("Documento não encontrado no projeto informado."); + } + + // Reindexação idempotente: substitui chunks anteriores do mesmo documento. + await client.query( + "DELETE FROM chunk WHERE entidade_tipo = 'documento' AND entidade_id = $1", + [input.documentoId], + ); + + for (const chunk of input.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)`, + [ + input.projetoId, + input.documentoId, + chunk.content, + JSON.stringify({ ...(chunk.metadata ?? {}), chunk_index: chunk.chunk_index }), + toVectorLiteral(chunk.embedding), + ], + ); + } + + await client.query( + "UPDATE documento SET status_processamento = 'processado', updated_at = CURRENT_TIMESTAMP WHERE id = $1", + [input.documentoId], + ); + + await auditService.record( + { + entidade_tipo: "documento", + entidade_id: input.documentoId, + acao: "INDEXAR_DOCUMENTO", + dados_json: { + projeto_id: input.projetoId, + total_chunks: input.chunks.length, + fonte: "n8n_pipeline", + }, + }, + client, + ); + + await client.query("COMMIT"); + return { + documento_id: input.documentoId, + projeto_id: input.projetoId, + total_chunks: input.chunks.length, + status_processamento: "processado", + }; + } catch (error) { + await client.query("ROLLBACK"); + if (error instanceof AppError) throw error; + throw new AppError("Não foi possível persistir os chunks agora.", 503, "CHUNK_PERSIST_FAILED"); + } finally { + client.release(); + } + } +} + +export const chunksService = new ChunksService(); 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..e7e340b --- /dev/null +++ b/backend/src/modules/documents/document-ingestion.ts @@ -0,0 +1,63 @@ +import axios from "axios"; +import { env } from "../../config/env.js"; +import { AppError } from "../../shared/errors.js"; +import { serviceHeaders } from "../../shared/service-auth.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: { ...serviceHeaders(), "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..c027665 100644 --- a/backend/src/modules/documents/documents.controller.ts +++ b/backend/src/modules/documents/documents.controller.ts @@ -7,16 +7,6 @@ function paramOf(value: string | string[] | undefined): string { return value ?? ""; } -function decodeFileName(header: string | string[] | undefined): string { - const raw = Array.isArray(header) ? header[0] : header; - if (!raw) throw new ValidationError("Informe o nome do arquivo no cabeçalho X-File-Name."); - try { - return decodeURIComponent(raw); - } catch { - throw new ValidationError("Nome do arquivo inválido."); - } -} - export class DocumentsController { constructor(private readonly service: DocumentsService = documentsService) {} @@ -32,12 +22,13 @@ export class DocumentsController { upload = async (req: Request, res: Response, next: NextFunction): Promise => { try { - if (!Buffer.isBuffer(req.body)) throw new ValidationError("Envie o arquivo como corpo binário da requisição."); + if (!req.file) throw new ValidationError("Envie o arquivo no campo 'file' como multipart/form-data."); + if (!Buffer.isBuffer(req.file.buffer)) throw new ValidationError("Arquivo inválido."); const created = await this.service.upload({ projetoId: paramOf(req.params.projectId), usuarioId: req.auth?.id ?? "", - fileName: decodeFileName(req.headers["x-file-name"]), - content: req.body, + fileName: req.file.originalname, + content: req.file.buffer, }); res.status(201).json(created); } catch (error) { @@ -45,6 +36,13 @@ export class DocumentsController { } }; + reprocess = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + await this.service.reprocess(paramOf(req.params.projectId), paramOf(req.params.documentId)); + res.status(202).json({ status: "pendente" }); + } catch (error) { next(error); } + }; + remove = async (req: Request, res: Response, next: NextFunction): Promise => { try { await this.service.remove({ @@ -57,6 +55,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..df1bfa6 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); @@ -207,7 +241,7 @@ test("S1-19/S1-22: documentos, auditoria e outbox no PostgreSQL", { skip: !proce const archiver = await pool.connect(); try { await archiver.query("BEGIN"); - await lockHierarchy(archiver); + await lockHierarchy(archiver, "projeto", archivedProject); await archiver.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [archivedProject]); const write = repo.create({ id: attemptedDocument, @@ -223,7 +257,7 @@ test("S1-19/S1-22: documentos, auditoria e outbox no PostgreSQL", { skip: !proce let waiting = false; while (!waiting && Date.now() < deadline) { const result = await pool.query<{ waiting: boolean }>( - "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE 'LOCK TABLE projeto,%') AS waiting", + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE '%FOR UPDATE OF p%') AS waiting", ); waiting = result.rows[0].waiting; if (!waiting) await new Promise((resolve) => setTimeout(resolve, 10)); @@ -260,14 +294,14 @@ test("S1-19/S1-22: documentos, auditoria e outbox no PostgreSQL", { skip: !proce const archiver = await pool.connect(); try { await archiver.query("BEGIN"); - await lockHierarchy(archiver); + await lockHierarchy(archiver, "projeto", archivedProject); await archiver.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [archivedProject]); const removal = repo.remove({ id: archivedDocument, projetoId: archivedProject, usuarioId: user }); const deadline = Date.now() + 2_000; let waiting = false; while (!waiting && Date.now() < deadline) { const result = await pool.query<{ waiting: boolean }>( - "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE 'LOCK TABLE projeto,%') AS waiting", + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE '%FOR UPDATE OF p%') AS waiting", ); waiting = result.rows[0].waiting; if (!waiting) await new Promise((resolve) => setTimeout(resolve, 10)); @@ -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, 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); @@ -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 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)]]); 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..3619769 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,11 +88,125 @@ 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, "projeto", projectId); + 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 { await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "projeto", input.projetoId); await assertWritable(client, "projeto", input.projetoId); await client.query( `INSERT INTO documento (id, projeto_id, nome, extensao, mime, tamanho_bytes, caminho, usuario_id, status_processamento) @@ -128,7 +245,7 @@ export class DocumentsRepository { const client = await this.pool.connect(); try { await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "projeto", input.projetoId); await assertWritable(client, "projeto", input.projetoId); const deleted = await client.query( `DELETE FROM documento WHERE id = $1 AND projeto_id = $2 diff --git a/backend/src/modules/documents/documents.routes.test.ts b/backend/src/modules/documents/documents.routes.test.ts index a650da4..efa1e27 100644 --- a/backend/src/modules/documents/documents.routes.test.ts +++ b/backend/src/modules/documents/documents.routes.test.ts @@ -46,10 +46,10 @@ after(() => { server.close(); }); -function upload(projectId: string, body: Buffer, fileName: string | null = "escopo.pdf", contentType = "application/octet-stream") { - const headers: Record = { "Content-Type": contentType }; - if (fileName !== null) headers["X-File-Name"] = encodeURIComponent(fileName); - return fetch(`${baseUrl}/${projectId}/documents`, { method: "POST", headers, body: new Uint8Array(body) }); +function upload(projectId: string, body: Buffer, fileName: string = "escopo.pdf") { + const formData = new FormData(); + formData.append("file", new Blob([body]), fileName); + return fetch(`${baseUrl}/${projectId}/documents`, { method: "POST", body: formData }); } test("perfil de leitura não pode enviar nem remover documentos", async () => { @@ -62,10 +62,10 @@ test("perfil de leitura não pode enviar nem remover documentos", async () => { test("upload válido retorna 201 com metadados e o tamanho vem do conteúdo, não do cliente", async () => { const content = pdfBuffer(100); - const response = await upload(PROJECT_ID, content, "Escopo Ação.pdf", "text/plain"); + const response = await upload(PROJECT_ID, content, "Escopo Acao.pdf"); assert.equal(response.status, 201); const body = await response.json() as { nome: string; mime: string; tamanho_bytes: number; status_processamento: string }; - assert.equal(body.nome, "Escopo Ação.pdf"); + assert.equal(body.nome, "Escopo Acao.pdf"); assert.equal(body.mime, "application/pdf"); assert.equal(body.tamanho_bytes, content.length); assert.equal(body.status_processamento, "pendente"); @@ -73,7 +73,7 @@ test("upload válido retorna 201 com metadados e o tamanho vem do conteúdo, nã test("MIME declarado falso é ignorado e o conteúdo real decide", async () => { const before = storage.files.size; - const response = await upload(PROJECT_ID, Buffer.from("isto é só texto"), "falso.pdf", "application/pdf"); + const response = await upload(PROJECT_ID, Buffer.from("isto é só texto"), "falso.pdf"); assert.equal(response.status, 400); assert.match(((await response.json()) as { error: string }).error, /não corresponde/); assert.equal(storage.files.size, before); @@ -88,14 +88,9 @@ test("arquivo acima do limite retorna 413 informando o limite", async () => { }); test("requisições malformadas são recusadas com 400", async () => { - assert.equal((await upload(PROJECT_ID, pdfBuffer(), null)).status, 400); + const formData = new FormData(); + assert.equal((await fetch(`${baseUrl}/${PROJECT_ID}/documents`, { method: "POST", body: formData })).status, 400); assert.equal((await upload(PROJECT_ID, Buffer.alloc(0), "vazio.txt")).status, 400); - const json = await fetch(`${baseUrl}/${PROJECT_ID}/documents`, { - method: "POST", - headers: { "Content-Type": "application/json", "X-File-Name": "a.txt" }, - body: JSON.stringify({ arquivo: "a" }), - }); - assert.equal(json.status, 400); }); test("projeto inexistente retorna 404 e id malformado retorna 400", async () => { @@ -127,3 +122,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..e54c935 100644 --- a/backend/src/modules/documents/documents.routes.ts +++ b/backend/src/modules/documents/documents.routes.ts @@ -1,4 +1,5 @@ -import express, { Router } from "express"; +import express, { Router, NextFunction, Request, Response } from "express"; +import multer from "multer"; import { env } from "../../config/env.js"; import { requireRole } from "../../middleware/requireRole.js"; import { DocumentsController, documentsController } from "./documents.controller.js"; @@ -10,7 +11,10 @@ export function createDocumentsRouter( maxBytes: number = Math.floor(env.DOCUMENT_MAX_SIZE_MB * 1024 * 1024), ): Router { const router = Router({ mergeParams: true }); - const binaryBody = express.raw({ type: () => true, limit: maxBytes }); + const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: maxBytes }, + }); /** * @swagger @@ -34,10 +38,22 @@ export function createDocumentsRouter( * @swagger * /api/v1/projects/{projectId}/documents: * post: - * summary: Enviar documento (PDF, DOCX, MD ou TXT) como corpo binário + * summary: Enviar documento (PDF, DOCX, MD ou TXT) como multipart/form-data * tags: [Documents] * security: * - bearerAuth: [] + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: + * - file + * properties: + * file: + * type: string + * format: binary * responses: * 201: * description: Documento armazenado @@ -50,7 +66,15 @@ export function createDocumentsRouter( * 413: * description: Arquivo acima do limite configurado */ - router.post("/", canWrite, binaryBody, controller.upload); + router.post("/", canWrite, upload.single("file"), (err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => { + if (err instanceof Error && (err.name === 'LimitExceedError' || err.message.includes('File too large'))) { + res.status(413).json({ code: 'PAYLOAD_TOO_LARGE', details: { max_bytes: maxBytes } }); + return; + } + next(err); + }, controller.upload); + + router.post("/:documentId/retry", canWrite, controller.retry); /** * @swagger @@ -68,6 +92,8 @@ export function createDocumentsRouter( * 404: * description: Projeto não encontrado */ + router.post("/:documentId/reprocess", canWrite, controller.reprocess); + router.delete("/:documentId", canWrite, controller.remove); return router; diff --git a/backend/src/modules/documents/documents.service.test.ts b/backend/src/modules/documents/documents.service.test.ts index e8c0412..309929e 100644 --- a/backend/src/modules/documents/documents.service.test.ts +++ b/backend/src/modules/documents/documents.service.test.ts @@ -40,7 +40,7 @@ test("upload válido persiste projeto, nome original, tipo, tamanho, autor e gua assert.equal(repository.audit[0].acao, "ENVIAR_DOCUMENTO"); const [key] = [...storage.files.keys()]; - assert.match(key, /^[0-9a-f-]{36}\/[0-9a-f-]{36}$/); + assert.match(key, /^[0-9a-f-]{36}\/[0-9a-f-]{36}\.pdf$/); assert.ok(!key.includes("Escopo")); assert.deepEqual(storage.files.get(key), content); }); @@ -101,7 +101,7 @@ test("projeto inexistente, inválido ou arquivado não recebe documentos", async test("listagem retorna apenas documentos do projeto e informa os limites", async () => { const { service, repository } = setup(); - repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho: `${PROJECT_ID}/${DOCUMENT_ID}` }); + repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho: `${PROJECT_ID}/${DOCUMENT_ID}.pdf` }); repository.seed({ id: "c0000000-0000-4000-8000-000000000002", projeto_id: OTHER_PROJECT_ID, caminho: "x/y" }); const result = await service.list(PROJECT_ID); @@ -113,7 +113,7 @@ test("listagem retorna apenas documentos do projeto e informa os limites", async test("remoção apaga metadados e arquivo, audita e não gera evento para documento não indexado", async () => { const { service, repository, storage, publisher } = setup(); - const caminho = `${PROJECT_ID}/${DOCUMENT_ID}`; + const caminho = `${PROJECT_ID}/${DOCUMENT_ID}.pdf`; repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho }); storage.files.set(caminho, pdfBuffer()); @@ -129,7 +129,7 @@ test("remoção apaga metadados e arquivo, audita e não gera evento para docume test("remoção de documento indexado publica evento com identificadores estáveis", async () => { const { service, repository, storage, publisher } = setup(); - const caminho = `${PROJECT_ID}/${DOCUMENT_ID}`; + const caminho = `${PROJECT_ID}/${DOCUMENT_ID}.pdf`; repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho, status_processamento: "processado" }); repository.chunks.set(DOCUMENT_ID, 7); storage.files.set(caminho, pdfBuffer()); @@ -149,24 +149,24 @@ test("remoção de documento indexado publica evento com identificadores estáve test("repetir a remoção não falha, não duplica o evento e não afeta outro conteúdo", async () => { const { service, repository, storage, publisher } = setup(); const otherId = "c0000000-0000-4000-8000-000000000002"; - repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho: `${PROJECT_ID}/${DOCUMENT_ID}`, status_processamento: "processado" }); - repository.seed({ id: otherId, projeto_id: PROJECT_ID, caminho: `${PROJECT_ID}/${otherId}` }); - storage.files.set(`${PROJECT_ID}/${DOCUMENT_ID}`, pdfBuffer()); - storage.files.set(`${PROJECT_ID}/${otherId}`, pdfBuffer()); + repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho: `${PROJECT_ID}/${DOCUMENT_ID}.pdf`, status_processamento: "processado" }); + repository.seed({ id: otherId, projeto_id: PROJECT_ID, caminho: `${PROJECT_ID}/${otherId}.pdf` }); + storage.files.set(`${PROJECT_ID}/${DOCUMENT_ID}.pdf`, pdfBuffer()); + storage.files.set(`${PROJECT_ID}/${otherId}.pdf`, pdfBuffer()); await service.remove({ projetoId: PROJECT_ID, documentoId: DOCUMENT_ID, usuarioId: USER_ID }); await service.remove({ projetoId: PROJECT_ID, documentoId: DOCUMENT_ID, usuarioId: USER_ID }); await service.flushPendingEvents(); assert.deepEqual(repository.rows.map((row) => row.id), [otherId]); - assert.ok(storage.files.has(`${PROJECT_ID}/${otherId}`)); + assert.ok(storage.files.has(`${PROJECT_ID}/${otherId}.pdf`)); assert.equal(publisher.published.length, 1); assert.equal(repository.audit.filter((item) => item.acao === "REMOVER_DOCUMENTO").length, 1); }); test("documento de outro projeto não é removido pelo caminho de um projeto alheio", async () => { const { service, repository, storage } = setup(); - const caminho = `${OTHER_PROJECT_ID}/${DOCUMENT_ID}`; + const caminho = `${OTHER_PROJECT_ID}/${DOCUMENT_ID}.pdf`; repository.seed({ id: DOCUMENT_ID, projeto_id: OTHER_PROJECT_ID, caminho }); storage.files.set(caminho, pdfBuffer()); @@ -179,7 +179,7 @@ test("documento de outro projeto não é removido pelo caminho de um projeto alh test("falha ao remover mantém o documento e restaura o arquivo", async () => { const { service, repository, storage } = setup(); - const caminho = `${PROJECT_ID}/${DOCUMENT_ID}`; + const caminho = `${PROJECT_ID}/${DOCUMENT_ID}.pdf`; repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho }); storage.files.set(caminho, pdfBuffer()); repository.failRemove = true; @@ -195,7 +195,7 @@ test("falha ao remover mantém o documento e restaura o arquivo", async () => { test("falha no armazenamento ao remover mantém o documento", async () => { const { service, repository, storage } = setup(); - const caminho = `${PROJECT_ID}/${DOCUMENT_ID}`; + const caminho = `${PROJECT_ID}/${DOCUMENT_ID}.pdf`; repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho }); storage.files.set(caminho, pdfBuffer()); storage.failStage = true; @@ -206,7 +206,7 @@ test("falha no armazenamento ao remover mantém o documento", async () => { test("evento não publicado fica pendente e pode ser reenviado pelo worker sem outra remoção", async () => { const { service, repository, storage, publisher } = setup(); - const caminho = `${PROJECT_ID}/${DOCUMENT_ID}`; + const caminho = `${PROJECT_ID}/${DOCUMENT_ID}.pdf`; repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho, status_processamento: "processado" }); storage.files.set(caminho, pdfBuffer()); publisher.available = false; @@ -225,7 +225,7 @@ test("evento não publicado fica pendente e pode ser reenviado pelo worker sem o test("DELETE em projeto arquivado retorna conflito e preserva documento e arquivo", async () => { const { service, repository, storage } = setup(); - const caminho = `${ARCHIVED_PROJECT_ID}/${DOCUMENT_ID}`; + const caminho = `${ARCHIVED_PROJECT_ID}/${DOCUMENT_ID}.pdf`; repository.seed({ id: DOCUMENT_ID, projeto_id: ARCHIVED_PROJECT_ID, caminho }); storage.files.set(caminho, pdfBuffer()); @@ -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..005e4eb 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,12 +62,20 @@ 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 { return { max_bytes: this.maxBytes, extensoes_permitidas: [...ALLOWED_EXTENSIONS] }; } + async reprocess(projetoId: string, documentoId: string): Promise { + // Mantido para compat: delega para retryProcessing (nome oficial pos-S2-01) + // que recoloca o documento como pendente e deixa o worker em background + // retomar via claim/lease. HttpDocumentIngestionClient nao expoe .retry(). + await this.retryProcessing(projetoId, documentoId); + } + private async requireProject(projetoId: string): Promise<{ id: string; status: string }> { validateUuid(projetoId, "ID do projeto"); const project = await this.projects.findById(projetoId); @@ -97,7 +106,7 @@ export class DocumentsService { } const inspected = inspectDocument(input.fileName, input.content, this.maxBytes); const id = randomUUID(); - const caminho = `${input.projetoId}/${id}`; + const caminho = `${input.projetoId}/${id}${inspected.extensao}`; try { await this.storage.save(caminho, input.content); } catch { @@ -134,6 +143,12 @@ export class DocumentsService { // and the background worker retries without creating duplicate metadata. created.armazenamento_pendente = true; } + + // A ingestao e disparada pelo DocumentIngestionWorker (S2-01), que faz + // claim idempotente do documento recem-criado e chama o ai-service + // diretamente (via DOCUMENT_INGEST_WEBHOOK_URL ou AI_SERVICE_URL/ingest/file). + // Chamar o webhook do n8n daqui tambem geraria dupla ingestao com shapes + // de payload incompativeis. return created; } @@ -212,12 +227,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 +272,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.test.ts b/backend/src/modules/documents/documents.storage.test.ts index 14f7a74..daf38be 100644 --- a/backend/src/modules/documents/documents.storage.test.ts +++ b/backend/src/modules/documents/documents.storage.test.ts @@ -7,7 +7,7 @@ import { LocalDocumentStorage } from "./documents.storage.js"; const PROJECT = "a0000000-0000-4000-8000-000000000001"; const DOCUMENT = "c0000000-0000-4000-8000-000000000001"; -const KEY = `${PROJECT}/${DOCUMENT}`; +const KEY = `${PROJECT}/${DOCUMENT}.pdf`; let directory: string; let storage: LocalDocumentStorage; @@ -46,7 +46,7 @@ test("remoção em duas fases: restaura em caso de falha e descarta após confir }); test("recusa chaves fora do formato para impedir travessia de diretório", async () => { - for (const key of ["../fora", "a/b", `${PROJECT}/../${DOCUMENT}`, `${PROJECT}/${DOCUMENT}/extra`]) { + for (const key of ["../fora", "a/b", `${PROJECT}/../${DOCUMENT}`, `${PROJECT}/${DOCUMENT}/extra`, `${PROJECT}/${DOCUMENT}.pdf/extra`]) { await assert.rejects(storage.save(key, Buffer.from("x")), /inválido/, key); } }); diff --git a/backend/src/modules/documents/documents.storage.ts b/backend/src/modules/documents/documents.storage.ts index bb5f12f..b6b21d8 100644 --- a/backend/src/modules/documents/documents.storage.ts +++ b/backend/src/modules/documents/documents.storage.ts @@ -1,12 +1,13 @@ -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}$/; +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}(?:\.(?:pdf|docx|md|txt))?$/; const STAGING_SUFFIX = ".removing"; 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; @@ -33,7 +34,7 @@ export class LocalDocumentStorage implements DocumentStorage { async save(key: string, content: Buffer): Promise { const path = this.pathOf(key); - await mkdir(dirname(path), { recursive: true }); + await mkdir(dirname(path), { recursive: true, mode: 0o755 }); try { await access(path); const duplicate = new Error("Arquivo já existe.") as NodeJS.ErrnoException; @@ -42,7 +43,9 @@ export class LocalDocumentStorage implements DocumentStorage { } catch (error) { if (!isMissing(error)) throw error; } - await writeFile(`${path}${UPLOAD_SUFFIX}`, content, { flag: "wx", mode: 0o600 }); + // 0o644 (não 0o600): o volume /files é compartilhado com o container do n8n, + // que roda como o usuário não-root "node" e precisa ler o arquivo para a ingestão. + await writeFile(`${path}${UPLOAD_SUFFIX}`, content, { flag: "wx", mode: 0o644 }); } async finalizeUpload(key: string): Promise { @@ -57,6 +60,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([ @@ -120,7 +127,8 @@ export class LocalDocumentStorage implements DocumentStorage { : null; if (!suffix) continue; const name = file.name.slice(0, -suffix.length); - if (!/^[0-9a-f-]{36}$/.test(name)) continue; + // Allow UUID or UUID.extension pattern + if (!/^[0-9a-f-]{36}(?:\.[a-z0-9]+)?$/.test(name)) continue; const key = `${project.name}/${name}`; const filePath = join(directory, file.name); const fileInfo = await stat(filePath).catch(() => null); diff --git a/backend/src/modules/documents/documents.types.ts b/backend/src/modules/documents/documents.types.ts index 9fedd0b..7824532 100644 --- a/backend/src/modules/documents/documents.types.ts +++ b/backend/src/modules/documents/documents.types.ts @@ -12,7 +12,14 @@ export interface DocumentRecord { mime: string | null; tamanho_bytes: number | null; status_processamento: DocumentStatus; + processamento_erro?: string | null; + processamento_tentativas?: number; armazenamento_pendente: boolean; + nova_tentativa_pendente?: boolean; + tentativas_processamento?: number; + proxima_tentativa_em?: string | null; + erro_processamento?: string | null; + erro_processamento_codigo?: string | null; autor_id: string | null; autor_nome: string | null; created_at: string; @@ -50,6 +57,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/epics/epics.compat.routes.ts b/backend/src/modules/epics/epics.compat.routes.ts index ba454b7..e14b2c5 100644 --- a/backend/src/modules/epics/epics.compat.routes.ts +++ b/backend/src/modules/epics/epics.compat.routes.ts @@ -48,7 +48,7 @@ export function createEpicsCompatRouter(service: EpicsService = epicsService) { const items: EpicWithStats[] = []; // The old contract is an unpaginated array; never silently truncate it to 100 records. for (let offset = 0; ; offset += 100) { - const page = await service.list({ projeto_id: projectId, limit: 100, offset }); + const page = await service.list({ projeto_id: projectId, limit: 100, offset }, req.auth!.id); items.push(...page.items); if (page.items.length < 100 || items.length >= page.total) break; } diff --git a/backend/src/modules/epics/epics.controller.ts b/backend/src/modules/epics/epics.controller.ts index 375b4dd..70faa79 100644 --- a/backend/src/modules/epics/epics.controller.ts +++ b/backend/src/modules/epics/epics.controller.ts @@ -21,7 +21,7 @@ export class EpicsController { list = async (req: Request, res: Response, next: NextFunction): Promise => { try { - const result = await this.service.list(req.query); + const result = await this.service.list(req.query, req.auth!.id); res.status(200).json(result); } catch (error) { next(error); diff --git a/backend/src/modules/epics/epics.repository.ts b/backend/src/modules/epics/epics.repository.ts index 3a02af6..740d239 100644 --- a/backend/src/modules/epics/epics.repository.ts +++ b/backend/src/modules/epics/epics.repository.ts @@ -1,11 +1,14 @@ -import { lockHierarchy, assertWritable } from "../projects/hierarchy-archive.js"; +import { projectAccessSql } from "../projects/project-access.js"; +import { ValidationError } from "../../shared/errors.js"; import { Pool, PoolClient } from "pg"; import { pool } from "../../database/db.js"; -import { CreateEpicDTO, UpdateEpicDTO, EpicQueryDTO, Epic, EpicWithStats, PaginatedEpics } from "./epics.types.js"; +import { withTransaction } from "../../database/transaction.js"; +import { buildAuditChangeData } from "../audit/audit.payloads.js"; import { auditService } from "../audit/audit.service.js"; +import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; import { assertJustificationForCompletedItem } from "../quality/completed-item-policy.js"; -import { buildAuditChangeData } from "../audit/audit.payloads.js"; import { getEntityTechnologyIds, replaceEntityTechnologies } from "../technologies/entity-technologies.js"; +import { EPIC_REQUIRED_FIELDS, CreateEpicDTO, Epic, EpicQueryDTO, EpicWithStats, PaginatedEpics, UpdateEpicDTO } from "./epics.types.js"; export class EpicsRepository { private pool: Pool; @@ -14,7 +17,7 @@ export class EpicsRepository { this.pool = customPool ?? pool; } - async findById(id: string): Promise { + async findById(id: string, executor: Pool | PoolClient = this.pool): Promise { const query = ` SELECT e.*, @@ -26,16 +29,14 @@ export class EpicsRepository { JOIN projeto p ON p.id = e.projeto_id WHERE e.id = $1 `; - const result = await this.pool.query(query, [id]); + const result = await executor.query(query, [id]); return result.rows[0] ?? null; } async create(data: CreateEpicDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "projeto", data.projeto_id); await assertWritable(client, "projeto", data.projeto_id); const insertQuery = ` @@ -68,20 +69,19 @@ export class EpicsRepository { client, ); - await client.query("COMMIT"); return created; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + + }); } - async findAll(query: EpicQueryDTO): Promise { + async findAll(query: EpicQueryDTO, userId?: string): Promise { const whereConditions: string[] = []; const params: unknown[] = []; let paramIndex = 1; + if (userId) { + whereConditions.push(projectAccessSql("e.projeto_id", "$" + paramIndex++)); + params.push(userId); + } if (query.projeto_id) { whereConditions.push(`e.projeto_id = $${paramIndex}`); @@ -120,18 +120,21 @@ export class EpicsRepository { } async update(id: string, data: UpdateEpicDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "epico", id); await assertWritable(client, "epico", id); - const existing = await this.findById(id); + const existing = await this.findById(id, client); if (!existing) { - await client.query("ROLLBACK"); + return null; } + if (existing.status === "concluido") { + const merged = { ...existing, ...data }; + const missing = EPIC_REQUIRED_FIELDS.filter(field => !String(merged[field] ?? "").trim()); + if (missing.length) throw new ValidationError("Não é possível remover campos obrigatórios de um item concluído.", { campos_faltantes: missing }); + } await assertJustificationForCompletedItem( client, "epico", @@ -178,30 +181,30 @@ export class EpicsRepository { client, ); - await client.query("COMMIT"); - return await this.findById(id); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + const saved = await this.findById(id, client); + + return saved; + + }); } async markConcluded(id: string, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "epico", id); await assertWritable(client, "epico", id); - const existing = await this.findById(id); + const existing = await this.findById(id, client); if (!existing) { - await client.query("ROLLBACK"); + return null; } + if (existing.status === "concluido") return existing; + const missing: string[] = EPIC_REQUIRED_FIELDS.filter(field => !String(existing[field] ?? "").trim()); + if (!existing.criterios_count) missing.push("criterios_aceitacao"); + if (missing.length) throw new ValidationError("Preencha os campos obrigatórios antes de concluir.", { campos_faltantes: missing }); + await client.query(`UPDATE epico SET status = 'concluido', updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [id]); await auditService.record( @@ -215,14 +218,11 @@ export class EpicsRepository { client, ); - await client.query("COMMIT"); - return await this.findById(id); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + const saved = await this.findById(id, client); + + return saved; + + }); } } diff --git a/backend/src/modules/epics/epics.routes.test.ts b/backend/src/modules/epics/epics.routes.test.ts index 1539375..e37e92d 100644 --- a/backend/src/modules/epics/epics.routes.test.ts +++ b/backend/src/modules/epics/epics.routes.test.ts @@ -69,6 +69,7 @@ test("Testes de integração HTTP - Rotas de Épicos", async (t) => { const testApp = express(); testApp.use(express.json()); + testApp.use((req,_res,next)=>{req.auth={id:"10000000-0000-4000-8000-000000000001",nome:"PO",email:"po@test",role:"po"};next()}); const router = express.Router(); router.post("/", controller.create); diff --git a/backend/src/modules/epics/epics.service.ts b/backend/src/modules/epics/epics.service.ts index 5f218d8..1b0476f 100644 --- a/backend/src/modules/epics/epics.service.ts +++ b/backend/src/modules/epics/epics.service.ts @@ -42,14 +42,14 @@ export class EpicsService { return await this.repository.create(dto, usuarioId); } - async list(queryInput: unknown): Promise { + async list(queryInput: unknown, userId?: string): Promise { const parseResult = epicQuerySchema.safeParse(queryInput); if (!parseResult.success) { const issue = parseResult.error.issues[0]; throw new ValidationError(issue.message, parseResult.error.format()); } - return await this.repository.findAll(parseResult.data); + return await this.repository.findAll(parseResult.data, userId); } async getById(id: string): Promise { diff --git a/backend/src/modules/features/features.controller.ts b/backend/src/modules/features/features.controller.ts index 7ad75eb..296ddcc 100644 --- a/backend/src/modules/features/features.controller.ts +++ b/backend/src/modules/features/features.controller.ts @@ -21,7 +21,7 @@ export class FeaturesController { list = async (req: Request, res: Response, next: NextFunction): Promise => { try { - const result = await this.service.list(req.query); + const result = await this.service.list(req.query, req.auth!.id); res.status(200).json(result); } catch (error) { next(error); diff --git a/backend/src/modules/features/features.repository.ts b/backend/src/modules/features/features.repository.ts index ffdb31c..6342f97 100644 --- a/backend/src/modules/features/features.repository.ts +++ b/backend/src/modules/features/features.repository.ts @@ -1,11 +1,14 @@ -import { lockHierarchy, assertWritable } from "../projects/hierarchy-archive.js"; +import { projectAccessSql } from "../projects/project-access.js"; +import { ValidationError } from "../../shared/errors.js"; import { Pool, PoolClient } from "pg"; import { pool } from "../../database/db.js"; -import { CreateFeatureDTO, UpdateFeatureDTO, FeatureQueryDTO, Feature, FeatureWithStats, PaginatedFeatures } from "./features.types.js"; +import { withTransaction } from "../../database/transaction.js"; +import { buildAuditChangeData } from "../audit/audit.payloads.js"; import { auditService } from "../audit/audit.service.js"; +import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; import { assertJustificationForCompletedItem } from "../quality/completed-item-policy.js"; -import { buildAuditChangeData } from "../audit/audit.payloads.js"; import { getEntityTechnologyIds, replaceEntityTechnologies } from "../technologies/entity-technologies.js"; +import { FEATURE_REQUIRED_FIELDS, CreateFeatureDTO, Feature, FeatureQueryDTO, FeatureWithStats, PaginatedFeatures, UpdateFeatureDTO } from "./features.types.js"; const SELECT_WITH_STATS = ` SELECT @@ -28,17 +31,15 @@ export class FeaturesRepository { this.pool = customPool ?? pool; } - async findById(id: string): Promise { - const result = await this.pool.query(`${SELECT_WITH_STATS} WHERE f.id = $1`, [id]); + async findById(id: string, executor: Pool | PoolClient = this.pool): Promise { + const result = await executor.query(`${SELECT_WITH_STATS} WHERE f.id = $1`, [id]); return result.rows[0] ?? null; } async create(data: CreateFeatureDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "epico", data.epico_id); await assertWritable(client, "epico", data.epico_id); const insertQuery = ` @@ -69,20 +70,19 @@ export class FeaturesRepository { client, ); - await client.query("COMMIT"); return created; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + + }); } - async findAll(query: FeatureQueryDTO): Promise { + async findAll(query: FeatureQueryDTO, userId?: string): Promise { const whereConditions: string[] = []; const params: unknown[] = []; let paramIndex = 1; + if (userId) { + whereConditions.push(projectAccessSql("(SELECT access_epic.projeto_id FROM epico access_epic WHERE access_epic.id=f.epico_id)", "$" + paramIndex++)); + params.push(userId); + } if (query.epico_id) { whereConditions.push(`f.epico_id = $${paramIndex}`); @@ -115,18 +115,21 @@ export class FeaturesRepository { } async update(id: string, data: UpdateFeatureDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "feature", id); await assertWritable(client, "feature", id); - const existing = await this.findById(id); + const existing = await this.findById(id, client); if (!existing) { - await client.query("ROLLBACK"); + return null; } + if (existing.status === "concluido") { + const merged = { ...existing, ...data }; + const missing = FEATURE_REQUIRED_FIELDS.filter(field => !String(merged[field] ?? "").trim()); + if (missing.length) throw new ValidationError("Não é possível remover campos obrigatórios de um item concluído.", { campos_faltantes: missing }); + } await assertJustificationForCompletedItem( client, "feature", @@ -171,30 +174,29 @@ export class FeaturesRepository { client, ); - await client.query("COMMIT"); - return await this.findById(id); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + const saved = await this.findById(id, client); + + return saved; + + }); } async markConcluded(id: string, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "feature", id); await assertWritable(client, "feature", id); - const existing = await this.findById(id); + const existing = await this.findById(id, client); if (!existing) { - await client.query("ROLLBACK"); + return null; } + if (existing.status === "concluido") return existing; + const missing: string[] = FEATURE_REQUIRED_FIELDS.filter(field => !String(existing[field] ?? "").trim()); + if (missing.length) throw new ValidationError("Preencha os campos obrigatórios antes de concluir.", { campos_faltantes: missing }); + await client.query(`UPDATE feature SET status = 'concluido', updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [id]); await auditService.record( @@ -208,14 +210,11 @@ export class FeaturesRepository { client, ); - await client.query("COMMIT"); - return await this.findById(id); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + const saved = await this.findById(id, client); + + return saved; + + }); } } diff --git a/backend/src/modules/features/features.service.ts b/backend/src/modules/features/features.service.ts index d032fd8..032c366 100644 --- a/backend/src/modules/features/features.service.ts +++ b/backend/src/modules/features/features.service.ts @@ -34,14 +34,14 @@ export class FeaturesService { return await this.repository.create(dto, usuarioId); } - async list(queryInput: unknown): Promise { + async list(queryInput: unknown, userId?: string): Promise { const parseResult = featureQuerySchema.safeParse(queryInput); if (!parseResult.success) { const issue = parseResult.error.issues[0]; throw new ValidationError(issue.message, parseResult.error.format()); } - return await this.repository.findAll(parseResult.data); + return await this.repository.findAll(parseResult.data, userId); } async getById(id: string): Promise { diff --git a/backend/src/modules/pbis/pbis.controller.ts b/backend/src/modules/pbis/pbis.controller.ts index ef8692c..9b32def 100644 --- a/backend/src/modules/pbis/pbis.controller.ts +++ b/backend/src/modules/pbis/pbis.controller.ts @@ -21,7 +21,7 @@ export class PbisController { list = async (req: Request, res: Response, next: NextFunction): Promise => { try { - const result = await this.service.list(req.query); + const result = await this.service.list(req.query, req.auth!.id); res.status(200).json(result); } catch (error) { next(error); diff --git a/backend/src/modules/pbis/pbis.repository.ts b/backend/src/modules/pbis/pbis.repository.ts index 07f3e16..af66776 100644 --- a/backend/src/modules/pbis/pbis.repository.ts +++ b/backend/src/modules/pbis/pbis.repository.ts @@ -1,11 +1,13 @@ -import { lockHierarchy, assertWritable } from "../projects/hierarchy-archive.js"; +import { projectAccessSql } from "../projects/project-access.js"; import { Pool, PoolClient } from "pg"; import { pool } from "../../database/db.js"; -import { CreatePbiDTO, UpdatePbiDTO, PbiQueryDTO, Pbi, PbiWithContext, PaginatedPbis } from "./pbis.types.js"; +import { withTransaction } from "../../database/transaction.js"; +import { buildAuditChangeData } from "../audit/audit.payloads.js"; import { auditService } from "../audit/audit.service.js"; +import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; import { assertJustificationForCompletedItem, normalizeJustification } from "../quality/completed-item-policy.js"; -import { buildAuditChangeData } from "../audit/audit.payloads.js"; import { getEntityTechnologyIds, replaceEntityTechnologies } from "../technologies/entity-technologies.js"; +import { CreatePbiDTO, PaginatedPbis, Pbi, PbiQueryDTO, PbiWithContext, UpdatePbiDTO } from "./pbis.types.js"; const SELECT_WITH_CONTEXT = ` SELECT @@ -31,17 +33,15 @@ export class PbisRepository { this.pool = customPool ?? pool; } - async findById(id: string): Promise { - const result = await this.pool.query(`${SELECT_WITH_CONTEXT} WHERE p.id = $1`, [id]); + async findById(id: string, executor: Pool | PoolClient = this.pool): Promise { + const result = await executor.query(`${SELECT_WITH_CONTEXT} WHERE p.id = $1`, [id]); return result.rows[0] ?? null; } async create(data: CreatePbiDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "feature", data.feature_id); await assertWritable(client, "feature", data.feature_id); const seqResult = await client.query<{ proxima_sequencia: number }>( @@ -84,20 +84,19 @@ export class PbisRepository { client, ); - await client.query("COMMIT"); return created; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + + }); } - async findAll(query: PbiQueryDTO): Promise { + async findAll(query: PbiQueryDTO, userId?: string): Promise { const whereConditions: string[] = []; const params: unknown[] = []; let paramIndex = 1; + if (userId) { + whereConditions.push(projectAccessSql("(SELECT access_epic.projeto_id FROM feature access_feature JOIN epico access_epic ON access_epic.id=access_feature.epico_id WHERE access_feature.id=p.feature_id)", "$" + paramIndex++)); + params.push(userId); + } if (query.feature_id) { whereConditions.push(`p.feature_id = $${paramIndex}`); @@ -130,16 +129,14 @@ export class PbisRepository { } async update(id: string, data: UpdatePbiDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "pbi", id); await assertWritable(client, "pbi", id); - const existing = await this.findById(id); + const existing = await this.findById(id, client); if (!existing) { - await client.query("ROLLBACK"); + return null; } await assertJustificationForCompletedItem( @@ -196,30 +193,31 @@ export class PbisRepository { [id, JSON.stringify(updated), normalizeJustification(data.justificativa) ?? "", usuarioId ?? null], ); - await client.query("COMMIT"); - return await this.findById(id); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + const saved = await this.findById(id, client); + + return saved; + + }); } - async markConcluded(id: string, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + async markConcluded(id: string, validate: (client: PoolClient) => Promise, usuarioId?: string | null): Promise { + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "pbi", id); await assertWritable(client, "pbi", id); - const existing = await this.findById(id); + const existing = await this.findById(id, client); if (!existing) { - await client.query("ROLLBACK"); + return null; } + if (existing.status === "concluido") { + + return existing; + } + await validate(client); + await client.query(`UPDATE pbi SET status = 'concluido', updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [id]); await auditService.record( @@ -233,14 +231,11 @@ export class PbisRepository { client, ); - await client.query("COMMIT"); - return await this.findById(id); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + const saved = await this.findById(id, client); + + return saved; + + }); } } diff --git a/backend/src/modules/pbis/pbis.routes.test.ts b/backend/src/modules/pbis/pbis.routes.test.ts index 6d61ff5..59420c7 100644 --- a/backend/src/modules/pbis/pbis.routes.test.ts +++ b/backend/src/modules/pbis/pbis.routes.test.ts @@ -1,3 +1,4 @@ +import type { PoolClient } from "pg"; import test, { after, before } from "node:test"; import assert from "node:assert/strict"; import express from "express"; @@ -50,6 +51,14 @@ class MockPbisRepo extends PbisRepository { return created; } + async markConcluded(id: string, validate: (client: PoolClient) => Promise): Promise { + const item = await this.findById(id); + if (!item) return null; + if (item.status !== "concluido") await validate({} as PoolClient); + item.status = "concluido"; + return item; + } + async findAll(query: PbiQueryDTO): Promise { return { items: this.pbis, total: this.pbis.length, limit: query.limit, offset: query.offset }; } diff --git a/backend/src/modules/pbis/pbis.service.test.ts b/backend/src/modules/pbis/pbis.service.test.ts index bf27165..77bc2e3 100644 --- a/backend/src/modules/pbis/pbis.service.test.ts +++ b/backend/src/modules/pbis/pbis.service.test.ts @@ -1,3 +1,4 @@ +import type { PoolClient } from "pg"; import test from "node:test"; import assert from "node:assert/strict"; import { PbisService } from "./pbis.service.js"; @@ -94,13 +95,16 @@ class InMemoryPbisRepository extends PbisRepository { } } - async markConcluded(id: string): Promise { + async markConcluded(id: string, validate: (client: PoolClient) => Promise): Promise { const index = this.pbis.findIndex((p) => p.id === id); if (index === -1) { return null; } + if (this.statusProjeto === "arquivado") throw new ValidationError("Não é possível alterar PBIs de um projeto arquivado."); + if (this.pbis[index].status !== "concluido") await validate({} as PoolClient); + this.pbis[index] = { ...this.pbis[index], status: "concluido", diff --git a/backend/src/modules/pbis/pbis.service.ts b/backend/src/modules/pbis/pbis.service.ts index 5d981cd..eacc8c3 100644 --- a/backend/src/modules/pbis/pbis.service.ts +++ b/backend/src/modules/pbis/pbis.service.ts @@ -45,14 +45,14 @@ export class PbisService { return created; } - async list(queryInput: unknown): Promise { + async list(queryInput: unknown, userId?: string): Promise { const parseResult = pbiQuerySchema.safeParse(queryInput); if (!parseResult.success) { const issue = parseResult.error.issues[0]; throw new ValidationError(issue.message, parseResult.error.format()); } - const page = await this.repository.findAll(parseResult.data); + const page = await this.repository.findAll(parseResult.data, userId); const reports = await this.qualityChecker.validatePbis(page.items); const items = page.items.map((pbi) => ({ @@ -128,41 +128,28 @@ export class PbisService { ): Promise { validateUuid(id, "ID do PBI"); - const existing = await this.repository.findById(id); - if (!existing) { - throw new NotFoundError("PBI não encontrado."); - } - - this.assertProjetoAtivo(existing); - - if (existing.status === "concluido") { - return existing; - } - - // O relatório contém somente as verificações ativas e aplicáveis da - // configuração vigente. Alertas informativos não fazem parte deste mapa e, - // portanto, nunca impedem a conclusão. - const qualityReport = await this.qualityChecker.validatePbi(id); - - const camposFaltantes = qualityReport.checks - .filter( - (check) => - !check.passed - && COMPLETION_BLOCKING_FIELDS[check.check_id], - ) - .map( - (check) => - COMPLETION_BLOCKING_FIELDS[check.check_id], - ); - - if (camposFaltantes.length > 0) { - throw new ValidationError( - "Não é possível concluir o PBI: corrija os itens de conformidade com o guia antes de concluir.", - { campos_faltantes: camposFaltantes }, - ); - } + const completed = await this.repository.markConcluded(id, async (client) => { + const qualityReport = await this.qualityChecker.validatePbi(id, client); + + const camposFaltantes = qualityReport.checks + .filter( + (check) => + !check.passed + && COMPLETION_BLOCKING_FIELDS[check.check_id], + ) + .map( + (check) => + COMPLETION_BLOCKING_FIELDS[check.check_id], + ); + + if (camposFaltantes.length > 0) { + throw new ValidationError( + "Não é possível concluir o PBI: corrija os itens de conformidade com o guia antes de concluir.", + { campos_faltantes: camposFaltantes }, + ); + } - const completed = await this.repository.markConcluded(id, usuarioId); + }, usuarioId); if (!completed) { throw new NotFoundError("PBI não encontrado."); diff --git a/backend/src/modules/priority-fixes.db.test.ts b/backend/src/modules/priority-fixes.db.test.ts new file mode 100644 index 0000000..72de780 --- /dev/null +++ b/backend/src/modules/priority-fixes.db.test.ts @@ -0,0 +1,225 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { validateTarget } from "../database/seed-lib.js"; +import { PbisRepository } from "./pbis/pbis.repository.js"; +import { PbisService } from "./pbis/pbis.service.js"; +import { EpicsRepository } from "./epics/epics.repository.js"; +import { FeaturesRepository } from "./features/features.repository.js"; +import { CriteriaRepository } from "./criteria/criteria.repository.js"; +import { QualityService, DatabaseQualityRuleConfigurationProvider } from "./quality/quality.service.js"; +import { QualityConfigurationRepository } from "./quality/quality.configuration.repository.js"; +import { AdminRepository } from "./admin/admin.repository.js"; +import { lockHierarchy } from "./projects/hierarchy-archive.js"; +import { ValidationError, NotFoundError } from "../shared/errors.js"; +import { ArchiveConflict } from "./projects/archive.types.js"; +import { ProjectsRepository } from "./projects/projects.repository.js"; + +const target = process.env.ARCHIVE_TEST_DATABASE_URL; + +test("correções prioritárias no PostgreSQL: transações, conclusão e demonstração", { skip: !target, timeout: 30000 }, async t => { + const connectionString = validateTarget(target, "test"); + const applicationName = "priority_" + randomUUID(); + const db = new Pool({ connectionString, max: 1, connectionTimeoutMillis: 1000, application_name: applicationName }); + const control = new Pool({ connectionString, max: 3 }); + const [project, otherProject, epic, feature, pbi, criterion, user] = Array.from({ length: 7 }, randomUUID); + const pbis = new PbisRepository(db); + const epics = new EpicsRepository(db); + const features = new FeaturesRepository(db); + const config = new QualityConfigurationRepository(db); + const quality = new QualityService(new CriteriaRepository(db), pbis, new DatabaseQualityRuleConfigurationProvider(config)); + const service = new PbisService(pbis, features, quality, config); + const admin = new AdminRepository(db); + async function waitForLock() { + for (let attempt = 0; attempt < 100; attempt++) { + const result = await control.query("SELECT 1 FROM pg_stat_activity WHERE application_name=$1 AND wait_event_type='Lock'", [applicationName]); + if (result.rowCount) return; + await new Promise(resolve => setTimeout(resolve, 20)); + } + throw new Error("A operação não aguardou o lock da transação concorrente"); + } + try { + await db.query("INSERT INTO usuario(id,nome,email,senha_hash,role) VALUES ($1,'Admin teste',$2,'hash','admin')", [user, user + "@test.local"]); + await db.query("INSERT INTO projeto(id,nome,cliente,status) VALUES ($1::uuid,$1::text,'Teste','ativo'),($2::uuid,$2::text,'Teste','ativo')", [project, otherProject]); + await db.query("INSERT INTO epico(id,projeto_id,titulo,status) VALUES ($1,$2,'Cadastrar acesso','rascunho')", [epic, project]); + await db.query("INSERT INTO feature(id,epico_id,titulo,status) VALUES ($1,$2,'Cadastrar acesso','rascunho')", [feature, epic]); + await db.query("INSERT INTO pbi(id,feature_id,codigo,titulo,historia_como_um,historia_eu_quero,historia_para_que,status) VALUES ($1::uuid,$2,$1::text,'Cadastrar acesso','PO','cadastrar acesso','organizar usuários','rascunho')", [pbi, feature]); + await db.query("INSERT INTO criterio_aceitacao(id,entidade_tipo,entidade_id,nome,dado,quando,entao,ordem) VALUES ($1,'pbi',$2,'Acesso','usuário válido','confirmar','acesso criado',1)", [criterion, pbi]); + + await t.test("alterações em outro projeto não esperam o projeto bloqueado", async () => { + const blocker = await control.connect(); + let updating: Promise | undefined; + let timer: ReturnType | undefined; + try { + await blocker.query("BEGIN"); + await lockHierarchy(blocker, "projeto", project); + updating = new AdminRepository(db).insertDemoChunks(otherProject, user); + const completed = await Promise.race([updating.then(() => true), new Promise(resolve => { timer=setTimeout(() => resolve(false), 1500); })]); + assert.equal(completed, true, "outro projeto ficou bloqueado"); + } finally { + clearTimeout(timer); + await blocker.query("ROLLBACK"); blocker.release(); + await updating; + await db.query("DELETE FROM chunk WHERE projeto_id=$1", [otherProject]); + await db.query("DELETE FROM auditoria WHERE entidade_id=$1", [otherProject]); + } + }); + + await db.query("UPDATE epico SET descricao='Descrição',objetivo='Objetivo',escopo_macro='Escopo',resultado_esperado='Resultado' WHERE id=$1",[epic]); + await db.query("UPDATE feature SET descricao='Descrição',objetivo='Objetivo' WHERE id=$1",[feature]); + await db.query("INSERT INTO criterio_aceitacao(entidade_tipo,entidade_id,texto,ordem) VALUES ('epico',$1,'Critério do épico',1)",[epic]); + + await t.test("conclusões revalidam os campos após aguardar o bloqueio", async () => { + for (const [entity, id, repository] of [["epico",epic,epics],["feature",feature,features]] as const) { + const blocker = await control.connect(); + let pending: Promise | undefined; + try { + await blocker.query("BEGIN"); + await lockHierarchy(blocker,"projeto",project); + pending = assert.rejects(repository.markConcluded(id,user),ValidationError); + await waitForLock(); + await blocker.query("UPDATE " + entity + " SET objetivo=NULL WHERE id=$1",[id]); + await blocker.query("COMMIT"); + await pending; + assert.equal((await repository.findById(id))?.status,"rascunho"); + } finally { + await blocker.query("ROLLBACK"); blocker.release(); + await pending; + await db.query("UPDATE " + entity + " SET objetivo='Objetivo' WHERE id=$1",[id]); + } + } + }); + + await t.test("épico revalida o último critério após aguardar o bloqueio", async () => { + const blocker = await control.connect(); + let pending: Promise | undefined; + try { + await blocker.query("BEGIN"); + await lockHierarchy(blocker,"projeto",project); + pending = assert.rejects(epics.markConcluded(epic,user),ValidationError); + await waitForLock(); + await blocker.query("DELETE FROM criterio_aceitacao WHERE entidade_tipo='epico' AND entidade_id=$1",[epic]); + await blocker.query("COMMIT"); + await pending; + assert.equal((await epics.findById(epic))?.status,"rascunho"); + } finally { + await blocker.query("ROLLBACK"); blocker.release(); + await pending; + await db.query("INSERT INTO criterio_aceitacao(entidade_tipo,entidade_id,texto,ordem) VALUES ('epico',$1,'Critério restaurado',1)",[epic]); + } + }); + + await t.test("updates e conclusões funcionam com pool de uma única conexão", async () => { + assert.equal((await epics.update(epic, { titulo: "Cadastrar clientes" }, user))?.titulo, "Cadastrar clientes"); + assert.equal((await features.update(feature, { titulo: "Cadastrar usuários" }, user))?.titulo, "Cadastrar usuários"); + assert.equal((await pbis.update(pbi, { titulo: "Cadastrar usuários" }, user))?.titulo, "Cadastrar usuários"); + assert.equal((await epics.markConcluded(epic, user))?.status, "concluido"); + assert.equal((await features.markConcluded(feature, user))?.status, "concluido"); + assert.equal((await service.complete(pbi, user)).status, "concluido"); + await service.complete(pbi, user); + assert.equal((await db.query("SELECT count(*)::int AS n FROM auditoria WHERE entidade_id=$1 AND acao='CONCLUIR_PBI'", [pbi])).rows[0].n, 1); + }); + + await t.test("campos obrigatórios não podem ser apagados após conclusão", async () => { + for (const value of [null, "", " "]) { + await assert.rejects(features.update(feature,{objetivo:value,justificativa:"Teste"},user),ValidationError); + await assert.rejects(features.update(feature,{descricao:value,justificativa:"Teste"},user),ValidationError); + await assert.rejects(epics.update(epic,{objetivo:value,justificativa:"Teste"},user),ValidationError); + } + assert.equal((await features.findById(feature))?.objetivo,"Objetivo"); + }); + + await t.test("falha de auditoria reverte atualização e libera a conexão", async () => { + const before = await pbis.findById(pbi); + await assert.rejects(pbis.update(pbi, { titulo: "Cadastrar outro acesso", justificativa: "Teste de rollback" }, randomUUID())); + assert.equal((await pbis.findById(pbi))?.titulo, before?.titulo); + }); + + await t.test("conclusão revalida critérios alterados enquanto espera o lock", async () => { + await db.query("UPDATE pbi SET status='rascunho' WHERE id=$1", [pbi]); + const blocker = await control.connect(); + let pending: Promise | undefined; + try { + await blocker.query("BEGIN"); + await lockHierarchy(blocker, "projeto", project); + pending = assert.rejects(service.complete(pbi, user), ValidationError); + await waitForLock(); + await blocker.query("DELETE FROM criterio_aceitacao WHERE id=$1", [criterion]); + await blocker.query("COMMIT"); + await pending; + assert.equal((await pbis.findById(pbi))?.status, "rascunho"); + assert.equal((await db.query("SELECT count(*)::int AS n FROM auditoria WHERE entidade_id=$1 AND acao='CONCLUIR_PBI'", [pbi])).rows[0].n, 1); + } finally { + await blocker.query("ROLLBACK"); + blocker.release(); + await pending?.catch(() => undefined); + } + }); + + await t.test("configuração de qualidade fica bloqueada até o commit", async () => { + await db.query("INSERT INTO criterio_aceitacao(id,entidade_tipo,entidade_id,nome,dado,quando,entao,ordem) VALUES ($1,'pbi',$2,'Acesso','usuário válido','confirmar','acesso criado',1)", [criterion, pbi]); + let release!: () => void; + let validated!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const ready = new Promise(resolve => { validated = resolve; }); + const completing = pbis.markConcluded(pbi, async client => { + await quality.validatePbi(pbi, client); + validated(); + await gate; + }, user); + try { + await Promise.race([ready, completing.then(() => { throw new Error("A validação não foi executada"); })]); + const editor = await control.connect(); + try { + await editor.query("BEGIN"); + await editor.query("SET LOCAL lock_timeout='100ms'"); + await assert.rejects(editor.query("UPDATE quality_configuration SET version=version+1"), (error: unknown) => (error as { code: string }).code === "55P03"); + } finally { await editor.query("ROLLBACK"); editor.release(); } + } finally { release(); await completing; } + }); + + await t.test("projetos preservam CRUD, snapshot e arquivamento com uma conexão", async () => { + const projects = new ProjectsRepository(db); + const created = await projects.create({ nome: randomUUID(), cliente: "Teste", status: "ativo" }, user); + try { + assert.equal((await projects.update(created.id, { nome: "Projeto organizado" }, user))?.nome, "Projeto organizado"); + await assert.rejects(projects.update(created.id, { nome: "Não persistir" }, randomUUID())); + assert.equal((await projects.findById(created.id))?.nome, "Projeto organizado"); + assert.equal((await projects.findBacklogTree(created.id))?.project.id, created.id); + const impact = (await projects.archiveImpact(created.id))!; + assert.equal((await projects.archive(created.id, user, "Encerrar teste", impact))?.status, "arquivado"); + assert.equal((await projects.archive(created.id, user))?.status, "arquivado"); + assert.equal(await projects.update(randomUUID(), { nome: "Ausente" }, user), null); + assert.equal(await projects.archive(randomUUID(), user), null); + assert.equal(await projects.findBacklogTree(randomUUID()), null); + } finally { + await db.query("DELETE FROM auditoria WHERE entidade_id=$1", [created.id]); + await db.query("DELETE FROM projeto WHERE id=$1", [created.id]); + } + }); + + await t.test("carga explícita é idempotente, auditada e não conta exemplos como vetores", async () => { + const before = await admin.counts(); + const peer = new AdminRepository(control); + const inserted = await Promise.all([admin.insertDemoChunks(project, user), peer.insertDemoChunks(project, user)]); + assert.deepEqual(inserted.sort(), [0, 2]); + assert.equal(await admin.insertDemoChunks(project, user), 0); + assert.equal((await db.query("SELECT count(*)::int n FROM chunk WHERE projeto_id=$1", [project])).rows[0].n, 2); + assert.equal((await db.query("SELECT count(*)::int n FROM chunk WHERE projeto_id=$1", [otherProject])).rows[0].n, 0); + assert.equal((await admin.counts()).chunksIndexados, before.chunksIndexados); + assert.equal((await db.query("SELECT count(*)::int n FROM auditoria WHERE entidade_id=$1 AND acao='CARREGAR_DEMONSTRACAO'", [project])).rows[0].n, 1); + await assert.rejects(admin.insertDemoChunks(otherProject, randomUUID())); + assert.equal((await db.query("SELECT count(*)::int n FROM chunk WHERE projeto_id=$1", [otherProject])).rows[0].n, 0); + await db.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [otherProject]); + await assert.rejects(admin.insertDemoChunks(otherProject, user), ArchiveConflict); + await assert.rejects(admin.insertDemoChunks(randomUUID(), user), NotFoundError); + }); + } finally { + await control.query("DELETE FROM auditoria WHERE usuario_id=$1", [user]); + await control.query("DELETE FROM projeto WHERE id=ANY($1::uuid[])", [[project, otherProject]]); + await control.query("DELETE FROM usuario WHERE id=$1", [user]); + await db.end(); + await control.end(); + } +}); diff --git a/backend/src/modules/projects/hierarchy-archive.db.test.ts b/backend/src/modules/projects/hierarchy-archive.db.test.ts index 8f43c3c..0e52a92 100644 --- a/backend/src/modules/projects/hierarchy-archive.db.test.ts +++ b/backend/src/modules/projects/hierarchy-archive.db.test.ts @@ -60,7 +60,7 @@ test("S1-09: arquivamento direto, preservação, filtros e escrita concorrente", // Simulate a request that passed its service check before an archive committed. const blocker = await db.connect(); await blocker.query("BEGIN"); - await lockHierarchy(blocker); + await lockHierarchy(blocker, "projeto", project); const writer = new FeaturesRepository(db).create({ epico_id: epic, titulo: "Tardia", prioridade: "Must" }); const rejected = assert.rejects(writer, ArchiveConflict); await blocker.query("COMMIT"); blocker.release(); diff --git a/backend/src/modules/projects/hierarchy-archive.ts b/backend/src/modules/projects/hierarchy-archive.ts index 72ffb91..072ee47 100644 --- a/backend/src/modules/projects/hierarchy-archive.ts +++ b/backend/src/modules/projects/hierarchy-archive.ts @@ -20,8 +20,15 @@ export const hierarchyArchiveSchema = z.object({ }); // The same lock order is used by archive and ordinary hierarchy writers. -export async function lockHierarchy(client: PoolClient) { - await client.query("LOCK TABLE projeto, epico, feature, pbi IN SHARE ROW EXCLUSIVE MODE"); +export async function lockHierarchy(client: PoolClient, kind: Kind | "projeto", id: string) { + const queries = { + projeto: "SELECT p.id FROM projeto p WHERE p.id=$1 FOR UPDATE OF p", + epico: "SELECT p.id FROM projeto p JOIN epico e ON e.projeto_id=p.id WHERE e.id=$1 FOR UPDATE OF p", + feature: "SELECT p.id FROM projeto p JOIN epico e ON e.projeto_id=p.id JOIN feature f ON f.epico_id=e.id WHERE f.id=$1 FOR UPDATE OF p", + pbi: "SELECT p.id FROM projeto p JOIN epico e ON e.projeto_id=p.id JOIN feature f ON f.epico_id=e.id JOIN pbi b ON b.feature_id=f.id WHERE b.id=$1 FOR UPDATE OF p", + }; + // Lock only the owning project. All hierarchy writers acquire this before child locks. + await client.query(queries[kind], [id]); } export async function assertWritable(client: PoolClient, kind: Kind | "projeto", id: string) { @@ -51,7 +58,7 @@ export class HierarchyArchiveRepository { const client = await this.db.connect(); try { await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, kind, id); const root = (await client.query(`SELECT * FROM ${kind} WHERE id=$1`, [id])).rows[0]; if (!root) throw new NotFoundError(); if (root.status === "arquivado") { await client.query("COMMIT"); return root; } diff --git a/backend/src/modules/projects/project-access.db.test.ts b/backend/src/modules/projects/project-access.db.test.ts new file mode 100644 index 0000000..28928f2 --- /dev/null +++ b/backend/src/modules/projects/project-access.db.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import express from "express"; +import type { AddressInfo } from "node:net"; +import { Pool } from "pg"; +import { ProjectAccess } from "./project-access.js"; +import { createProjectAccessRouter } from "../../middleware/projectAccess.js"; +import { errorHandler } from "../../middleware/errorHandler.js"; +import { validateTarget } from "../../database/seed-lib.js"; +import { ProjectsService } from "./projects.service.js"; +import { ProjectsRepository } from "./projects.repository.js"; +import { EpicsRepository } from "../epics/epics.repository.js"; +import { FeaturesRepository } from "../features/features.repository.js"; +import { PbisRepository } from "../pbis/pbis.repository.js"; + +test("isolamento: coleções, detalhes, descendentes e aliases", {skip:!process.env.ARCHIVE_TEST_DATABASE_URL}, async () => { + const db=new Pool({connectionString:validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL,"test")}); + const ids=Array.from({length:12},randomUUID); + const [user,developer,project,other,epic,otherEpic,feature,otherFeature,pbi,otherPbi,criterion,otherCriterion]=ids; + const app=express(); + for(const prefix of ["/api/v1","/api"]) app.use(prefix,createProjectAccessRouter(new ProjectAccess(db),(req,_res,next)=>{req.auth={id:user,nome:"Teste",email:"test@test",role:"dev"};next()})); + app.use((_req,res)=>res.sendStatus(200));app.use(errorHandler); + const server=app.listen(0,"127.0.0.1");await new Promise(r=>server.once("listening",r)); + const base="http://127.0.0.1:"+(server.address() as AddressInfo).port; + try { + await db.query("INSERT INTO usuario(id,nome,email,senha_hash,role) VALUES ($1,'Dev',$2,'h','dev')",[user,user+"@test.local"]); + await db.query("INSERT INTO desenvolvedor(id,usuario_id) VALUES ($1,$2)",[developer,user]); + for(const [pr,e,f,b,c] of [[project,epic,feature,pbi,criterion],[other,otherEpic,otherFeature,otherPbi,otherCriterion]]) { + await db.query("INSERT INTO projeto(id,nome,cliente) VALUES ($1::uuid,$1::text,'Teste')",[pr]); + await db.query("INSERT INTO epico(id,projeto_id,titulo) VALUES ($1,$2,'Épico')",[e,pr]); + await db.query("INSERT INTO feature(id,epico_id,titulo) VALUES ($1,$2,'Feature')",[f,e]); + await db.query("INSERT INTO pbi(id,feature_id,codigo,titulo,historia_como_um,historia_eu_quero,historia_para_que) VALUES ($1::uuid,$2,$1::text,'PBI','PO','testar','validar')",[b,f]); + await db.query("INSERT INTO criterio_aceitacao(id,entidade_tipo,entidade_id,texto,ordem) VALUES ($1,'epico',$2,'Critério',1)",[c,e]); + } + await db.query("INSERT INTO alocacao(desenvolvedor_id,projeto_id) VALUES ($1,$2)",[developer,project]); + const paths=(pr:string,e:string,f:string,b:string,c:string)=>[ + '/projects/'+pr, '/projects/'+pr+'/documents', '/projects/'+pr+'/epicos', '/projects/'+pr+'/backlog-tree', + '/projects/'+pr+'/repo-analyses', '/projects/'+pr+'/decisions','/projects/'+pr+'/backlog-search', + '/epics/'+e,'/epicos/'+e,'/features/'+f,'/pbis/'+b,'/criteria/'+c, + '/epics/'+e+'/decisions','/features/'+f+'/decisions','/pbis/'+b+'/decisions', + '/quality/pbis/'+b+'/quality','/audit/epico/'+e+'/history', '/criteria?entidade_tipo=epico&entidade_id='+e]; + for(const prefix of ['/api/v1','/api']) { + for(const path of paths(project,epic,feature,pbi,criterion)) assert.equal((await fetch(base+prefix+path)).status,200,path); + for(const path of paths(other,otherEpic,otherFeature,otherPbi,otherCriterion)) assert.equal((await fetch(base+prefix+path)).status,404,path); + } + const queries=[ + ()=>new ProjectsService(new ProjectsRepository(db)).list({},user), + ()=>new EpicsRepository(db).findAll({limit:50,offset:0},user), + ()=>new FeaturesRepository(db).findAll({limit:50,offset:0},user), + ()=>new PbisRepository(db).findAll({limit:50,offset:0},user), + ]; + for(const [index,query] of queries.entries()) { const result=await query();assert.equal(result.total,1);assert.equal(result.items[0].id,[project,epic,feature,pbi][index]); } + await db.query("UPDATE alocacao SET data_fim=CURRENT_TIMESTAMP WHERE desenvolvedor_id=$1",[developer]); + for(const query of queries) assert.equal((await query()).total,0); + assert.equal((await fetch(base+'/api/v1/projects/'+project+'/documents')).status,404); + } finally { + server.closeAllConnections();await new Promise(r=>server.close(()=>r())); + await db.query("DELETE FROM projeto WHERE id=ANY($1::uuid[])",[[project,other]]); + await db.query("DELETE FROM usuario WHERE id=$1",[user]);await db.end(); + } +}); diff --git a/backend/src/modules/projects/project-access.ts b/backend/src/modules/projects/project-access.ts new file mode 100644 index 0000000..80dccb5 --- /dev/null +++ b/backend/src/modules/projects/project-access.ts @@ -0,0 +1,33 @@ +import type { Pool } from "pg"; +import { pool } from "../../database/db.js"; +import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; + +// Arguments are SQL expressions controlled by repositories, never request input. +export function projectAccessSql(project: string, user: string): string { + return `EXISTS (SELECT 1 FROM usuario access_user WHERE access_user.id=${user} AND + (access_user.role IN ('admin','po') OR EXISTS ( + SELECT 1 FROM desenvolvedor access_dev JOIN alocacao access_allocation ON access_allocation.desenvolvedor_id=access_dev.id + WHERE access_dev.usuario_id=access_user.id AND access_allocation.projeto_id=${project} + AND access_allocation.data_inicio<=CURRENT_TIMESTAMP + AND (access_allocation.data_fim IS NULL OR access_allocation.data_fim>CURRENT_TIMESTAMP))))`; +} +const entities: Record = { + projeto: "SELECT id AS projeto_id FROM projeto WHERE id=$1", + epico: "SELECT projeto_id FROM epico WHERE id=$1", + feature: "SELECT e.projeto_id FROM feature f JOIN epico e ON e.id=f.epico_id WHERE f.id=$1", + pbi: "SELECT e.projeto_id FROM pbi b JOIN feature f ON f.id=b.feature_id JOIN epico e ON e.id=f.epico_id WHERE b.id=$1", +}; +export class ProjectAccess { + constructor(private readonly db: Pick = pool) {} + async assertEntity(userId: string, kind: string, id: string): Promise { + validateUuid(id,"ID da entidade"); + if (kind === "criterio") { + const row = (await this.db.query("SELECT entidade_tipo,entidade_id FROM criterio_aceitacao WHERE id=$1",[id])).rows[0]; + if (!row) throw new NotFoundError("Item não encontrado."); + return this.assertEntity(userId,row.entidade_tipo,row.entidade_id); + } + if (!Object.hasOwn(entities,kind)) throw new ValidationError("Tipo de entidade inválido."); + const result = await this.db.query(`SELECT scope.projeto_id FROM (${entities[kind]}) scope WHERE ${projectAccessSql("scope.projeto_id","$2")}`,[id,userId]); + if (!result.rowCount) throw new NotFoundError("Item não encontrado."); + } +} diff --git a/backend/src/modules/projects/projects.archive.ts b/backend/src/modules/projects/projects.archive.ts new file mode 100644 index 0000000..af94093 --- /dev/null +++ b/backend/src/modules/projects/projects.archive.ts @@ -0,0 +1,238 @@ +import { lockHierarchy } from "./hierarchy-archive.js"; +import { + Pool, + PoolClient, +} from "pg"; +import { withTransaction } from "../../database/transaction.js"; +import { auditService } from "../audit/audit.service.js"; +import { + ArchiveConflict, + type ArchiveImpact, +} from "./archive.types.js"; +import { ProjectQueries } from "./projects.queries.js"; +import { + Project, + ProjectWithStats +} from "./projects.types.js"; + +export class ProjectArchiveRepository { + constructor(private readonly pool: Pool) { } + + async archiveImpact( + id: string, + connection: + | Pool + | PoolClient = this.pool, + ): Promise { + const result = + await connection + .query( + ` + SELECT + CASE + WHEN root.status = 'arquivado' + THEN 0 + ELSE 1 + END AS projeto, + + ( + SELECT COUNT(*)::int + FROM epico + WHERE projeto_id = $1 + AND status != 'arquivado' + ) AS epicos, + + ( + SELECT COUNT(*)::int + FROM feature f + JOIN epico e + ON e.id = f.epico_id + WHERE e.projeto_id = $1 + AND f.status != 'arquivado' + ) AS features, + + ( + SELECT COUNT(*)::int + FROM pbi p + JOIN feature f + ON f.id = p.feature_id + JOIN epico e + ON e.id = f.epico_id + WHERE e.projeto_id = $1 + AND p.status != 'arquivado' + ) AS pbis + + FROM projeto root + WHERE root.id = $1 + `, + [id], + ); + + return result.rows[0] ?? null; + } + + async archive( + id: string, + usuarioId?: string | null, + justificativa?: string, + expected?: ArchiveImpact, + ): Promise { + return withTransaction(this.pool, async (client) => { + + await lockHierarchy(client, "projeto", id); + + const existingResult = + await client.query( + ` + SELECT * + FROM projeto + WHERE id = $1 + FOR UPDATE + `, + [id], + ); + + const existingProject = + existingResult.rows[0]; + + if (!existingProject) { + + return null; + } + + if ( + existingProject.status + === "arquivado" + ) { + + return existingProject; + } + + const activeAnalysis = await client.query( + "SELECT 1 FROM analise_repositorio WHERE projeto_id=$1 AND status NOT IN ('concluido','falha','cancelada') LIMIT 1", [id]); + if (activeAnalysis.rowCount) throw new ArchiveConflict("Conclua ou cancele a análise de repositório antes de arquivar o projeto."); + + const impact = ( + await this.archiveImpact( + id, + client, + ) + )!; + + if ( + expected && + ( + Object.keys( + impact, + ) as ( + keyof ArchiveImpact + )[] + ).some( + (key) => + impact[key] + !== expected[key], + ) + ) { + throw new ArchiveConflict( + "A quantidade de itens mudou. Consulte a prévia e confirme novamente.", + ); + } + + const updateQuery = ` + UPDATE projeto + SET + status = 'arquivado', + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING * + `; + + await client.query( + updateQuery, + [id], + ); + + await client.query( + ` + UPDATE epico + SET + status = 'arquivado', + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE projeto_id = $1 + AND status != 'arquivado' + `, + [id], + ); + + await client.query( + ` + UPDATE feature + SET + status = 'arquivado', + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE epico_id IN ( + SELECT id + FROM epico + WHERE projeto_id = $1 + ) + AND status != 'arquivado' + `, + [id], + ); + + await client.query( + ` + UPDATE pbi + SET + status = 'arquivado', + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE feature_id IN ( + SELECT f.id + FROM feature f + JOIN epico e + ON e.id = f.epico_id + WHERE e.projeto_id = $1 + ) + AND status != 'arquivado' + `, + [id], + ); + + await auditService.record( + { + usuario_id: + usuarioId ?? null, + + entidade_tipo: + "projeto", + + entidade_id: id, + + acao: + "ARQUIVAR_PROJETO", + + justificativa: + justificativa ?? null, + + dados_json: { + status_anterior: + existingProject.status, + + status_novo: + "arquivado", + + impacto: impact, + }, + }, + client, + ); + + return await new ProjectQueries(this.pool).findById(id, client); + + }); + } +} diff --git a/backend/src/modules/projects/projects.backlog.ts b/backend/src/modules/projects/projects.backlog.ts new file mode 100644 index 0000000..77aea7f --- /dev/null +++ b/backend/src/modules/projects/projects.backlog.ts @@ -0,0 +1,321 @@ +import { + Pool +} from "pg"; +import { withTransaction } from "../../database/transaction.js"; +import { + BacklogTechnology, + Project, + ProjectBacklogTree +} from "./projects.types.js"; + +export class ProjectBacklogRepository { + constructor(private readonly pool: Pool) { } + + async findBacklogTree( + id: string, + ): Promise { + return withTransaction(this.pool, async (client) => { + + const projectResult = + await client.query< + Pick< + Project, + "id" | "nome" | "status" + > + >( + ` + SELECT + id, + nome, + status + FROM projeto + WHERE id = $1 + `, + [id], + ); + + const project = + projectResult.rows[0]; + + if (!project) { + + return null; + } + + type ItemRow = { + id: string; + titulo: string; + status: string; + tecnologias: BacklogTechnology[]; + }; + + type FeatureRow = + ItemRow & { + epico_id: string; + }; + + type PbiRow = + ItemRow & { + feature_id: string; + codigo: string; + }; + + const technologyJoin = ( + entityAlias: string, + entityType: string, + ) => ` + LEFT JOIN entidade_tecnologia et + ON et.entidade_id = ${entityAlias}.id + AND et.entidade_tipo = '${entityType}' + LEFT JOIN tecnologia t + ON t.id = et.tecnologia_id + `; + + const technologyAggregate = ` + COALESCE( + jsonb_agg( + DISTINCT jsonb_build_object( + 'id', + t.id, + 'nome', + t.nome + ) + ) FILTER ( + WHERE t.id IS NOT NULL + ), + '[]'::jsonb + ) AS tecnologias + `; + + const epicsResult = await client.query( + ` + SELECT + e.id, + e.titulo, + e.status, + ${technologyAggregate} + FROM epico e + ${technologyJoin( + "e", + "epico", + )} + WHERE e.projeto_id = $1 + GROUP BY e.id + ORDER BY + e.created_at ASC, + e.id ASC + `, + [id], + ); + + const featuresResult = await client.query( + ` + SELECT + f.id, + f.epico_id, + f.titulo, + f.status, + ${technologyAggregate} + FROM feature f + JOIN epico e + ON e.id = f.epico_id + ${technologyJoin( + "f", + "feature", + )} + WHERE e.projeto_id = $1 + GROUP BY f.id + ORDER BY + f.created_at ASC, + f.id ASC + `, + [id], + ); + + const pbisResult = await client.query( + ` + SELECT + p.id, + p.feature_id, + p.codigo, + p.titulo, + p.status, + ${technologyAggregate} + FROM pbi p + JOIN feature f + ON f.id = p.feature_id + JOIN epico e + ON e.id = f.epico_id + ${technologyJoin( + "p", + "pbi", + )} + WHERE e.projeto_id = $1 + GROUP BY p.id + ORDER BY + p.created_at ASC, + p.id ASC + `, + [id], + ); + + const pbisByFeature = + new Map< + string, + PbiRow[] + >(); + + for (const pbi of pbisResult.rows) { + const current = + pbisByFeature.get( + pbi.feature_id, + ) ?? []; + + current.push(pbi); + + pbisByFeature.set( + pbi.feature_id, + current, + ); + } + + const featuresByEpic = + new Map< + string, + Array< + FeatureRow & { + pbis: PbiRow[]; + } + > + >(); + + for ( + const feature + of featuresResult.rows + ) { + const current = + featuresByEpic.get( + feature.epico_id, + ) ?? []; + + current.push({ + ...feature, + + pbis: + pbisByFeature.get( + feature.id, + ) ?? [], + }); + + featuresByEpic.set( + feature.epico_id, + current, + ); + } + + const technologies = + new Map< + string, + BacklogTechnology + >(); + + const collectTechnologies = ( + items: Array<{ + tecnologias: + BacklogTechnology[]; + }>, + ) => { + for (const item of items) { + for ( + const technology + of item.tecnologias + ) { + technologies.set( + technology.id, + technology, + ); + } + } + }; + + collectTechnologies( + epicsResult.rows, + ); + + collectTechnologies( + featuresResult.rows, + ); + + collectTechnologies( + pbisResult.rows, + ); + + const tree: ProjectBacklogTree = { + project, + + epics: + epicsResult.rows.map( + (epic) => ({ + id: epic.id, + titulo: epic.titulo, + status: epic.status, + + tecnologias: + epic.tecnologias, + + features: ( + featuresByEpic.get( + epic.id, + ) ?? [] + ).map( + (feature) => ({ + id: feature.id, + + titulo: + feature.titulo, + + status: + feature.status, + + tecnologias: + feature.tecnologias, + + pbis: + feature.pbis.map( + (pbi) => ({ + id: pbi.id, + + codigo: + pbi.codigo, + + titulo: + pbi.titulo, + + status: + pbi.status, + + tecnologias: + pbi.tecnologias, + }), + ), + }), + ), + }), + ), + + technologies: [ + ...technologies.values(), + ].sort( + (a, b) => + a.nome.localeCompare( + b.nome, + "pt-BR", + ), + ), + }; + + return tree; + + }, "snapshot"); + } +} diff --git a/backend/src/modules/projects/projects.commands.ts b/backend/src/modules/projects/projects.commands.ts new file mode 100644 index 0000000..8d69804 --- /dev/null +++ b/backend/src/modules/projects/projects.commands.ts @@ -0,0 +1,301 @@ +import { + Pool +} from "pg"; +import { withTransaction } from "../../database/transaction.js"; +import { auditService } from "../audit/audit.service.js"; +import { + ArchiveConflict +} from "./archive.types.js"; +import { ProjectQueries } from "./projects.queries.js"; +import { + CreateProjectDTO, + Project, + ProjectWithStats, + UpdateProjectDTO +} from "./projects.types.js"; + +export class ProjectCommands { + constructor(private readonly pool: Pool) { } + + async create( + data: CreateProjectDTO, + usuarioId?: string | null, + ): Promise { + return withTransaction(this.pool, async (client) => { + + const insertProjectQuery = ` + INSERT INTO projeto ( + nome, + cliente, + descricao, + status, + data_inicio + ) + VALUES ( + $1, + $2, + $3, + $4, + COALESCE( + $5, + CURRENT_TIMESTAMP + ) + ) + RETURNING * + `; + + const projectValues = [ + data.nome.trim(), + data.cliente.trim(), + data.descricao?.trim() + ?? null, + data.status ?? "ativo", + data.data_inicio ?? null, + ]; + + const projectResult = + await client.query( + insertProjectQuery, + projectValues, + ); + + const createdProject = + projectResult.rows[0]; + + await auditService.record( + { + usuario_id: + usuarioId ?? null, + + entidade_tipo: + "projeto", + + entidade_id: + createdProject.id, + + acao: + "CRIAR_PROJETO", + + dados_json: { + nome: + createdProject.nome, + + cliente: + createdProject.cliente, + + descricao: + createdProject.descricao, + + status: + createdProject.status, + + data_inicio: + createdProject.data_inicio, + }, + }, + client, + ); + + return createdProject; + + }); + } + + async update( + id: string, + data: UpdateProjectDTO, + usuarioId?: string | null, + ): Promise { + return withTransaction(this.pool, async (client) => { + + const existingResult = + await client.query( + ` + SELECT * + FROM projeto + WHERE id = $1 + FOR UPDATE + `, + [id], + ); + + const existingProject = + existingResult.rows[0]; + + if (!existingProject) { + + return null; + } + + if ( + existingProject.status + === "arquivado" + ) { + throw new ArchiveConflict( + "Projeto arquivado está disponível apenas para leitura.", + ); + } + + if ( + String(data.status) + === "arquivado" + ) { + throw new ArchiveConflict( + "Use a ação de arquivamento com prévia e confirmação.", + ); + } + + const updates: string[] = []; + const values: unknown[] = []; + + let valIndex = 1; + + if ( + data.nome !== undefined + ) { + updates.push( + `nome = $${valIndex}`, + ); + + values.push( + data.nome.trim(), + ); + + valIndex++; + } + + if ( + data.cliente !== undefined + ) { + updates.push( + `cliente = $${valIndex}`, + ); + + values.push( + data.cliente.trim(), + ); + + valIndex++; + } + + if ( + data.descricao + !== undefined + ) { + updates.push( + `descricao = $${valIndex}`, + ); + + values.push( + data.descricao?.trim() + ?? null, + ); + + valIndex++; + } + + if ( + data.status !== undefined + ) { + updates.push( + `status = $${valIndex}`, + ); + + values.push(data.status); + valIndex++; + } + + if ( + data.data_inicio + !== undefined + ) { + updates.push( + `data_inicio = $${valIndex}`, + ); + + values.push( + data.data_inicio ?? null, + ); + + valIndex++; + } + + updates.push( + "updated_at = CURRENT_TIMESTAMP", + ); + + values.push(id); + + const updateQuery = ` + UPDATE projeto + SET ${updates.join(", ")} + WHERE id = $${valIndex} + RETURNING * + `; + + const result = + await client.query( + updateQuery, + values, + ); + + const updated = + result.rows[0]; + + await auditService.record( + { + usuario_id: + usuarioId ?? null, + + entidade_tipo: + "projeto", + + entidade_id: id, + + acao: + "ATUALIZAR_PROJETO", + + justificativa: + data.justificativa + ?? null, + + dados_json: { + alteracoes: data, + + anterior: { + nome: + existingProject.nome, + + cliente: + existingProject.cliente, + + descricao: + existingProject.descricao, + + status: + existingProject.status, + }, + + novo: { + nome: + updated.nome, + + cliente: + updated.cliente, + + descricao: + updated.descricao, + + status: + updated.status, + }, + }, + }, + client, + ); + + return await new ProjectQueries(this.pool).findById(id, client); + + }); + } +} diff --git a/backend/src/modules/projects/projects.controller.ts b/backend/src/modules/projects/projects.controller.ts index 4f4be07..30a0690 100644 --- a/backend/src/modules/projects/projects.controller.ts +++ b/backend/src/modules/projects/projects.controller.ts @@ -90,7 +90,7 @@ export class ProjectsController { try { const result = await this.service.list( - req.query, + req.query, req.auth!.id, ); res.status(200).json(result); diff --git a/backend/src/modules/projects/projects.queries.ts b/backend/src/modules/projects/projects.queries.ts new file mode 100644 index 0000000..a344e9c --- /dev/null +++ b/backend/src/modules/projects/projects.queries.ts @@ -0,0 +1,248 @@ +import { projectAccessSql } from "../projects/project-access.js"; +import { + Pool, + PoolClient, +} from "pg"; +import { + PaginatedProjects, + Project, + ProjectQueryDTO, + ProjectWithStats +} from "./projects.types.js"; + +export class ProjectQueries { + constructor(private readonly pool: Pool) { } + + async findActiveByName( + nome: string, + excludeId?: string, + ): Promise { + const params: unknown[] = [ + nome.trim(), + ]; + + let query = ` + SELECT * + FROM projeto + WHERE LOWER(TRIM(nome)) = LOWER(TRIM($1)) + AND status != 'arquivado' + `; + + if (excludeId) { + params.push(excludeId); + query += ` AND id != $2`; + } + + query += ` LIMIT 1`; + + const result = + await this.pool.query( + query, + params, + ); + + return result.rows[0] ?? null; + } + + async findById( + id: string, + connection: Pool | PoolClient = this.pool, + ): Promise { + const query = ` + SELECT + p.id, + p.nome, + p.cliente, + p.descricao, + p.status, + p.data_inicio, + p.created_at, + p.updated_at, + p.archived_at, + COALESCE( + ( + SELECT COUNT(*)::int + FROM epico e + WHERE e.projeto_id = p.id + ), + 0 + ) AS epicos_count, + COALESCE( + ( + SELECT COUNT(*)::int + FROM documento d + WHERE d.projeto_id = p.id + ), + 0 + ) AS documentos_count + FROM projeto p + WHERE p.id = $1 + `; + + const result = + await connection + .query( + query, + [id], + ); + + return result.rows[0] ?? null; + } + + async findAll( + query: ProjectQueryDTO, + userId?: string, + ): Promise { + const whereConditions: + string[] = []; + + const params: unknown[] = []; + + let paramIndex = 1; + if (userId) { + whereConditions.push(projectAccessSql("p.id", "$" + paramIndex++)); + params.push(userId); + } + + if (!query.status) { + whereConditions.push( + "p.status != 'arquivado'", + ); + } + + if ( + query.status && + query.status !== "todos" + ) { + whereConditions.push( + `p.status = $${paramIndex}`, + ); + + params.push(query.status); + paramIndex++; + } + + if ( + query.busca && + query.busca.trim().length > 0 + ) { + whereConditions.push( + `( + p.nome ILIKE $${paramIndex} + OR p.cliente ILIKE $${paramIndex} + OR p.descricao ILIKE $${paramIndex} + )`, + ); + + params.push( + `%${query.busca.trim()}%`, + ); + + paramIndex++; + } + + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join( + " AND ", + )}` + : ""; + + const countQuery = ` + SELECT + COUNT(*)::int AS total + FROM projeto p + ${whereClause} + `; + + const countResult = + await this.pool.query<{ + total: number; + }>( + countQuery, + params, + ); + + const total = + countResult.rows[0]?.total + ?? 0; + + let orderByClause = + "ORDER BY p.created_at DESC"; + + if ( + query.order + === "created_at_asc" + ) { + orderByClause = + "ORDER BY p.created_at ASC"; + } else if ( + query.order === "nome_asc" + ) { + orderByClause = + "ORDER BY p.nome ASC"; + } else if ( + query.order === "nome_desc" + ) { + orderByClause = + "ORDER BY p.nome DESC"; + } + + const dataParams = [ + ...params, + query.limit, + query.offset, + ]; + + const dataQuery = ` + SELECT + p.id, + p.nome, + p.cliente, + p.descricao, + p.status, + p.data_inicio, + p.created_at, + p.updated_at, + p.archived_at, + + COALESCE( + ( + SELECT COUNT(*)::int + FROM epico e + WHERE e.projeto_id = p.id + ), + 0 + ) AS epicos_count, + + COALESCE( + ( + SELECT COUNT(*)::int + FROM documento d + WHERE d.projeto_id = p.id + ), + 0 + ) AS documentos_count + + FROM projeto p + ${whereClause} + ${orderByClause} + LIMIT $${paramIndex} + OFFSET $${paramIndex + 1} + `; + + const dataResult = + await this.pool + .query( + dataQuery, + dataParams, + ); + + return { + items: dataResult.rows, + total, + limit: query.limit, + offset: query.offset, + }; + } +} diff --git a/backend/src/modules/projects/projects.repository.ts b/backend/src/modules/projects/projects.repository.ts index 2945162..e2677c0 100644 --- a/backend/src/modules/projects/projects.repository.ts +++ b/backend/src/modules/projects/projects.repository.ts @@ -3,720 +3,84 @@ import { PoolClient, } from "pg"; import { pool } from "../../database/db.js"; +import { + type ArchiveImpact +} from "./archive.types.js"; +import { ProjectArchiveRepository } from "./projects.archive.js"; +import { ProjectBacklogRepository } from "./projects.backlog.js"; +import { ProjectCommands } from "./projects.commands.js"; +import { ProjectQueries } from "./projects.queries.js"; import { CreateProjectDTO, - UpdateProjectDTO, - ProjectQueryDTO, - Project, - ProjectWithStats, PaginatedProjects, + Project, ProjectBacklogTree, - BacklogTechnology, + ProjectQueryDTO, + ProjectWithStats, + UpdateProjectDTO } from "./projects.types.js"; -import { auditService } from "../audit/audit.service.js"; -import { - ArchiveConflict, - type ArchiveImpact, -} from "./archive.types.js"; export class ProjectsRepository { - private pool: Pool; - - constructor(customPool?: Pool) { - this.pool = customPool ?? pool; + private readonly queries: ProjectQueries; + private readonly backlog: ProjectBacklogRepository; + private readonly archiver: ProjectArchiveRepository; + private readonly commands: ProjectCommands; + + constructor(customPool: Pool = pool) { + this.queries = new ProjectQueries(customPool); + this.backlog = new ProjectBacklogRepository(customPool); + this.archiver = new ProjectArchiveRepository(customPool); + this.commands = new ProjectCommands(customPool); } async findActiveByName( nome: string, excludeId?: string, ): Promise { - const params: unknown[] = [ - nome.trim(), - ]; - - let query = ` - SELECT * - FROM projeto - WHERE LOWER(TRIM(nome)) = LOWER(TRIM($1)) - AND status != 'arquivado' - `; - - if (excludeId) { - params.push(excludeId); - query += ` AND id != $2`; - } - - query += ` LIMIT 1`; - - const result = - await this.pool.query( - query, - params, - ); - - return result.rows[0] ?? null; + return this.queries.findActiveByName(nome, excludeId); } async findById( id: string, + connection?: Pool | PoolClient, ): Promise { - const query = ` - SELECT - p.id, - p.nome, - p.cliente, - p.descricao, - p.status, - p.data_inicio, - p.created_at, - p.updated_at, - p.archived_at, - COALESCE( - ( - SELECT COUNT(*)::int - FROM epico e - WHERE e.projeto_id = p.id - ), - 0 - ) AS epicos_count, - COALESCE( - ( - SELECT COUNT(*)::int - FROM documento d - WHERE d.projeto_id = p.id - ), - 0 - ) AS documentos_count - FROM projeto p - WHERE p.id = $1 - `; - - const result = - await this.pool - .query( - query, - [id], - ); + return this.queries.findById(id, connection); + } - return result.rows[0] ?? null; + async findAll( + query: ProjectQueryDTO, + userId?: string, + ): Promise { + return this.queries.findAll(query, userId); } async findBacklogTree( id: string, ): Promise { - const client = await this.pool.connect(); - - try { - await client.query( - "BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY", - ); - const projectResult = - await client.query< - Pick< - Project, - "id" | "nome" | "status" - > - >( - ` - SELECT - id, - nome, - status - FROM projeto - WHERE id = $1 - `, - [id], - ); - - const project = - projectResult.rows[0]; - - if (!project) { - await client.query("COMMIT"); - return null; - } - - type ItemRow = { - id: string; - titulo: string; - status: string; - tecnologias: BacklogTechnology[]; - }; - - type FeatureRow = - ItemRow & { - epico_id: string; - }; - - type PbiRow = - ItemRow & { - feature_id: string; - codigo: string; - }; - - const technologyJoin = ( - entityAlias: string, - entityType: string, - ) => ` - LEFT JOIN entidade_tecnologia et - ON et.entidade_id = ${entityAlias}.id - AND et.entidade_tipo = '${entityType}' - LEFT JOIN tecnologia t - ON t.id = et.tecnologia_id - `; - - const technologyAggregate = ` - COALESCE( - jsonb_agg( - DISTINCT jsonb_build_object( - 'id', - t.id, - 'nome', - t.nome - ) - ) FILTER ( - WHERE t.id IS NOT NULL - ), - '[]'::jsonb - ) AS tecnologias - `; - - const epicsResult = await client.query( - ` - SELECT - e.id, - e.titulo, - e.status, - ${technologyAggregate} - FROM epico e - ${technologyJoin( - "e", - "epico", - )} - WHERE e.projeto_id = $1 - GROUP BY e.id - ORDER BY - e.created_at ASC, - e.id ASC - `, - [id], - ); - - const featuresResult = await client.query( - ` - SELECT - f.id, - f.epico_id, - f.titulo, - f.status, - ${technologyAggregate} - FROM feature f - JOIN epico e - ON e.id = f.epico_id - ${technologyJoin( - "f", - "feature", - )} - WHERE e.projeto_id = $1 - GROUP BY f.id - ORDER BY - f.created_at ASC, - f.id ASC - `, - [id], - ); - - const pbisResult = await client.query( - ` - SELECT - p.id, - p.feature_id, - p.codigo, - p.titulo, - p.status, - ${technologyAggregate} - FROM pbi p - JOIN feature f - ON f.id = p.feature_id - JOIN epico e - ON e.id = f.epico_id - ${technologyJoin( - "p", - "pbi", - )} - WHERE e.projeto_id = $1 - GROUP BY p.id - ORDER BY - p.created_at ASC, - p.id ASC - `, - [id], - ); - - const pbisByFeature = - new Map< - string, - PbiRow[] - >(); - - for (const pbi of pbisResult.rows) { - const current = - pbisByFeature.get( - pbi.feature_id, - ) ?? []; - - current.push(pbi); - - pbisByFeature.set( - pbi.feature_id, - current, - ); - } - - const featuresByEpic = - new Map< - string, - Array< - FeatureRow & { - pbis: PbiRow[]; - } - > - >(); - - for ( - const feature - of featuresResult.rows - ) { - const current = - featuresByEpic.get( - feature.epico_id, - ) ?? []; - - current.push({ - ...feature, - - pbis: - pbisByFeature.get( - feature.id, - ) ?? [], - }); - - featuresByEpic.set( - feature.epico_id, - current, - ); - } - - const technologies = - new Map< - string, - BacklogTechnology - >(); - - const collectTechnologies = ( - items: Array<{ - tecnologias: - BacklogTechnology[]; - }>, - ) => { - for (const item of items) { - for ( - const technology - of item.tecnologias - ) { - technologies.set( - technology.id, - technology, - ); - } - } - }; - - collectTechnologies( - epicsResult.rows, - ); - - collectTechnologies( - featuresResult.rows, - ); - - collectTechnologies( - pbisResult.rows, - ); - - const tree: ProjectBacklogTree = { - project, - - epics: - epicsResult.rows.map( - (epic) => ({ - id: epic.id, - titulo: epic.titulo, - status: epic.status, - - tecnologias: - epic.tecnologias, - - features: ( - featuresByEpic.get( - epic.id, - ) ?? [] - ).map( - (feature) => ({ - id: feature.id, - - titulo: - feature.titulo, - - status: - feature.status, - - tecnologias: - feature.tecnologias, - - pbis: - feature.pbis.map( - (pbi) => ({ - id: pbi.id, - - codigo: - pbi.codigo, - - titulo: - pbi.titulo, - - status: - pbi.status, - - tecnologias: - pbi.tecnologias, - }), - ), - }), - ), - }), - ), - - technologies: [ - ...technologies.values(), - ].sort( - (a, b) => - a.nome.localeCompare( - b.nome, - "pt-BR", - ), - ), - }; - await client.query("COMMIT"); - return tree; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + return this.backlog.findBacklogTree(id); } async archiveImpact( id: string, - connection: - | Pool - | PoolClient = this.pool, + connection?: Pool | PoolClient, ): Promise { - const result = - await connection - .query( - ` - SELECT - CASE - WHEN root.status = 'arquivado' - THEN 0 - ELSE 1 - END AS projeto, - - ( - SELECT COUNT(*)::int - FROM epico - WHERE projeto_id = $1 - AND status != 'arquivado' - ) AS epicos, - - ( - SELECT COUNT(*)::int - FROM feature f - JOIN epico e - ON e.id = f.epico_id - WHERE e.projeto_id = $1 - AND f.status != 'arquivado' - ) AS features, - - ( - SELECT COUNT(*)::int - FROM pbi p - JOIN feature f - ON f.id = p.feature_id - JOIN epico e - ON e.id = f.epico_id - WHERE e.projeto_id = $1 - AND p.status != 'arquivado' - ) AS pbis - - FROM projeto root - WHERE root.id = $1 - `, - [id], - ); + return this.archiver.archiveImpact(id, connection); + } - return result.rows[0] ?? null; + async archive( + id: string, + usuarioId?: string | null, + justificativa?: string, + expected?: ArchiveImpact, + ): Promise { + return this.archiver.archive(id, usuarioId, justificativa, expected); } async create( data: CreateProjectDTO, usuarioId?: string | null, ): Promise { - const client: PoolClient = - await this.pool.connect(); - - try { - await client.query("BEGIN"); - - const insertProjectQuery = ` - INSERT INTO projeto ( - nome, - cliente, - descricao, - status, - data_inicio - ) - VALUES ( - $1, - $2, - $3, - $4, - COALESCE( - $5, - CURRENT_TIMESTAMP - ) - ) - RETURNING * - `; - - const projectValues = [ - data.nome.trim(), - data.cliente.trim(), - data.descricao?.trim() - ?? null, - data.status ?? "ativo", - data.data_inicio ?? null, - ]; - - const projectResult = - await client.query( - insertProjectQuery, - projectValues, - ); - - const createdProject = - projectResult.rows[0]; - - await auditService.record( - { - usuario_id: - usuarioId ?? null, - - entidade_tipo: - "projeto", - - entidade_id: - createdProject.id, - - acao: - "CRIAR_PROJETO", - - dados_json: { - nome: - createdProject.nome, - - cliente: - createdProject.cliente, - - descricao: - createdProject.descricao, - - status: - createdProject.status, - - data_inicio: - createdProject.data_inicio, - }, - }, - client, - ); - - await client.query("COMMIT"); - - return createdProject; - } catch (error) { - await client.query( - "ROLLBACK", - ); - - throw error; - } finally { - client.release(); - } - } - - async findAll( - query: ProjectQueryDTO, - ): Promise { - const whereConditions: - string[] = []; - - const params: unknown[] = []; - - let paramIndex = 1; - - if (!query.status) { - whereConditions.push( - "p.status != 'arquivado'", - ); - } - - if ( - query.status && - query.status !== "todos" - ) { - whereConditions.push( - `p.status = $${paramIndex}`, - ); - - params.push(query.status); - paramIndex++; - } - - if ( - query.busca && - query.busca.trim().length > 0 - ) { - whereConditions.push( - `( - p.nome ILIKE $${paramIndex} - OR p.cliente ILIKE $${paramIndex} - OR p.descricao ILIKE $${paramIndex} - )`, - ); - - params.push( - `%${query.busca.trim()}%`, - ); - - paramIndex++; - } - - const whereClause = - whereConditions.length > 0 - ? `WHERE ${whereConditions.join( - " AND ", - )}` - : ""; - - const countQuery = ` - SELECT - COUNT(*)::int AS total - FROM projeto p - ${whereClause} - `; - - const countResult = - await this.pool.query<{ - total: number; - }>( - countQuery, - params, - ); - - const total = - countResult.rows[0]?.total - ?? 0; - - let orderByClause = - "ORDER BY p.created_at DESC"; - - if ( - query.order - === "created_at_asc" - ) { - orderByClause = - "ORDER BY p.created_at ASC"; - } else if ( - query.order === "nome_asc" - ) { - orderByClause = - "ORDER BY p.nome ASC"; - } else if ( - query.order === "nome_desc" - ) { - orderByClause = - "ORDER BY p.nome DESC"; - } - - const dataParams = [ - ...params, - query.limit, - query.offset, - ]; - - const dataQuery = ` - SELECT - p.id, - p.nome, - p.cliente, - p.descricao, - p.status, - p.data_inicio, - p.created_at, - p.updated_at, - p.archived_at, - - COALESCE( - ( - SELECT COUNT(*)::int - FROM epico e - WHERE e.projeto_id = p.id - ), - 0 - ) AS epicos_count, - - COALESCE( - ( - SELECT COUNT(*)::int - FROM documento d - WHERE d.projeto_id = p.id - ), - 0 - ) AS documentos_count - - FROM projeto p - ${whereClause} - ${orderByClause} - LIMIT $${paramIndex} - OFFSET $${paramIndex + 1} - `; - - const dataResult = - await this.pool - .query( - dataQuery, - dataParams, - ); - - return { - items: dataResult.rows, - total, - limit: query.limit, - offset: query.offset, - }; + return this.commands.create(data, usuarioId); } async update( @@ -724,404 +88,8 @@ export class ProjectsRepository { data: UpdateProjectDTO, usuarioId?: string | null, ): Promise { - const client: PoolClient = - await this.pool.connect(); - - try { - await client.query("BEGIN"); - - const existingResult = - await client.query( - ` - SELECT * - FROM projeto - WHERE id = $1 - FOR UPDATE - `, - [id], - ); - - const existingProject = - existingResult.rows[0]; - - if (!existingProject) { - await client.query( - "ROLLBACK", - ); - - return null; - } - - if ( - existingProject.status - === "arquivado" - ) { - throw new ArchiveConflict( - "Projeto arquivado está disponível apenas para leitura.", - ); - } - - if ( - String(data.status) - === "arquivado" - ) { - throw new ArchiveConflict( - "Use a ação de arquivamento com prévia e confirmação.", - ); - } - - const updates: string[] = []; - const values: unknown[] = []; - - let valIndex = 1; - - if ( - data.nome !== undefined - ) { - updates.push( - `nome = $${valIndex}`, - ); - - values.push( - data.nome.trim(), - ); - - valIndex++; - } - - if ( - data.cliente !== undefined - ) { - updates.push( - `cliente = $${valIndex}`, - ); - - values.push( - data.cliente.trim(), - ); - - valIndex++; - } - - if ( - data.descricao - !== undefined - ) { - updates.push( - `descricao = $${valIndex}`, - ); - - values.push( - data.descricao?.trim() - ?? null, - ); - - valIndex++; - } - - if ( - data.status !== undefined - ) { - updates.push( - `status = $${valIndex}`, - ); - - values.push(data.status); - valIndex++; - } - - if ( - data.data_inicio - !== undefined - ) { - updates.push( - `data_inicio = $${valIndex}`, - ); - - values.push( - data.data_inicio ?? null, - ); - - valIndex++; - } - - updates.push( - "updated_at = CURRENT_TIMESTAMP", - ); - - values.push(id); - - const updateQuery = ` - UPDATE projeto - SET ${updates.join(", ")} - WHERE id = $${valIndex} - RETURNING * - `; - - const result = - await client.query( - updateQuery, - values, - ); - - const updated = - result.rows[0]; - - await auditService.record( - { - usuario_id: - usuarioId ?? null, - - entidade_tipo: - "projeto", - - entidade_id: id, - - acao: - "ATUALIZAR_PROJETO", - - justificativa: - data.justificativa - ?? null, - - dados_json: { - alteracoes: data, - - anterior: { - nome: - existingProject.nome, - - cliente: - existingProject.cliente, - - descricao: - existingProject.descricao, - - status: - existingProject.status, - }, - - novo: { - nome: - updated.nome, - - cliente: - updated.cliente, - - descricao: - updated.descricao, - - status: - updated.status, - }, - }, - }, - client, - ); - - await client.query("COMMIT"); - - return await this.findById(id); - } catch (error) { - await client.query( - "ROLLBACK", - ); - - throw error; - } finally { - client.release(); - } - } - - async archive( - id: string, - usuarioId?: string | null, - justificativa?: string, - expected?: ArchiveImpact, - ): Promise { - const client: PoolClient = - await this.pool.connect(); - - try { - await client.query("BEGIN"); - - await client.query( - ` - LOCK TABLE - projeto, - epico, - feature, - pbi - IN SHARE ROW EXCLUSIVE MODE - `, - ); - - const existingResult = - await client.query( - ` - SELECT * - FROM projeto - WHERE id = $1 - FOR UPDATE - `, - [id], - ); - - const existingProject = - existingResult.rows[0]; - - if (!existingProject) { - await client.query( - "ROLLBACK", - ); - - return null; - } - - if ( - existingProject.status - === "arquivado" - ) { - await client.query( - "COMMIT", - ); - - return existingProject; - } - - const impact = ( - await this.archiveImpact( - id, - client, - ) - )!; - - if ( - expected && - ( - Object.keys( - impact, - ) as ( - keyof ArchiveImpact - )[] - ).some( - (key) => - impact[key] - !== expected[key], - ) - ) { - throw new ArchiveConflict( - "A quantidade de itens mudou. Consulte a prévia e confirme novamente.", - ); - } - - const updateQuery = ` - UPDATE projeto - SET - status = 'arquivado', - archived_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - RETURNING * - `; - - await client.query( - updateQuery, - [id], - ); - - await client.query( - ` - UPDATE epico - SET - status = 'arquivado', - archived_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP - WHERE projeto_id = $1 - AND status != 'arquivado' - `, - [id], - ); - - await client.query( - ` - UPDATE feature - SET - status = 'arquivado', - archived_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP - WHERE epico_id IN ( - SELECT id - FROM epico - WHERE projeto_id = $1 - ) - AND status != 'arquivado' - `, - [id], - ); - - await client.query( - ` - UPDATE pbi - SET - status = 'arquivado', - archived_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP - WHERE feature_id IN ( - SELECT f.id - FROM feature f - JOIN epico e - ON e.id = f.epico_id - WHERE e.projeto_id = $1 - ) - AND status != 'arquivado' - `, - [id], - ); - - await auditService.record( - { - usuario_id: - usuarioId ?? null, - - entidade_tipo: - "projeto", - - entidade_id: id, - - acao: - "ARQUIVAR_PROJETO", - - justificativa: - justificativa ?? null, - - dados_json: { - status_anterior: - existingProject.status, - - status_novo: - "arquivado", - - impacto: impact, - }, - }, - client, - ); - - await client.query("COMMIT"); - - return await this.findById(id); - } catch (error) { - await client.query( - "ROLLBACK", - ); - - throw error; - } finally { - client.release(); - } + return this.commands.update(id, data, usuarioId); } } -export const projectsRepository = - new ProjectsRepository(); +export const projectsRepository = new ProjectsRepository(); diff --git a/backend/src/modules/projects/projects.routes.test.ts b/backend/src/modules/projects/projects.routes.test.ts index 1e12dec..4571d67 100644 --- a/backend/src/modules/projects/projects.routes.test.ts +++ b/backend/src/modules/projects/projects.routes.test.ts @@ -279,6 +279,7 @@ test( const testApp = express(); testApp.use(express.json()); + testApp.use((req,_res,next)=>{req.auth={id:"10000000-0000-4000-8000-000000000001",nome:"PO",email:"po@test",role:"po"};next()}); const router = express.Router(); diff --git a/backend/src/modules/projects/projects.service.ts b/backend/src/modules/projects/projects.service.ts index f091600..9b99597 100644 --- a/backend/src/modules/projects/projects.service.ts +++ b/backend/src/modules/projects/projects.service.ts @@ -69,6 +69,7 @@ export class ProjectsService { async list( queryInput: unknown, + userId?: string, ): Promise { const parseResult = projectQuerySchema.safeParse( @@ -86,7 +87,7 @@ export class ProjectsService { } return await this.repository.findAll( - parseResult.data, + parseResult.data, userId, ); } diff --git a/backend/src/modules/quality/quality.service.ts b/backend/src/modules/quality/quality.service.ts index a4c173d..35e9686 100644 --- a/backend/src/modules/quality/quality.service.ts +++ b/backend/src/modules/quality/quality.service.ts @@ -1,3 +1,4 @@ +import type { PoolClient } from "pg"; import { CriteriaRepository, criteriaRepository } from "../criteria/criteria.repository.js"; import { PbisRepository, pbisRepository } from "../pbis/pbis.repository.js"; import { Pbi } from "../pbis/pbis.types.js"; @@ -41,7 +42,7 @@ export function calculateCompleteness(checks: QualityCheckResult[]): number | nu * singleton below uses the persisted, organization-wide S2-19 configuration. */ export class DefaultQualityRuleConfigurationProvider implements QualityRuleConfigurationProvider { - async getCurrentPbiConfiguration(): Promise { + async getCurrentPbiConfiguration(client?: PoolClient): Promise { return { rule_version: "pbi-quality-v1", checks: PBI_QUALITY_CHECKS.map((check_id) => ({ @@ -56,8 +57,8 @@ export class DefaultQualityRuleConfigurationProvider implements QualityRuleConfi export class DatabaseQualityRuleConfigurationProvider implements QualityRuleConfigurationProvider { constructor(private readonly configurationRepository = qualityConfigurationRepository) {} - async getCurrentPbiConfiguration(): Promise { - const current = await this.configurationRepository.getPbiConfiguration(); + async getCurrentPbiConfiguration(client?: PoolClient): Promise { + const current = await this.configurationRepository.getPbiConfiguration(client, { lock: Boolean(client) }); return { rule_version: current.rule_version, checks: PBI_QUALITY_CHECKS @@ -110,12 +111,12 @@ export class QualityService { } /** Computes the backlog indicator from the same rules used by the detail report. */ - async validatePbi(pbiId: string): Promise { - const pbi = await this.pbisRepo.findById(pbiId); + async validatePbi(pbiId: string, client?: PoolClient): Promise { + const pbi = await this.pbisRepo.findById(pbiId, client); if (!pbi) throw new NotFoundError("PBI não encontrado."); - const criteria = await this.criteriaRepo.listByEntity("pbi", pbiId); - const configuration = await this.rulesProvider.getCurrentPbiConfiguration(); + const criteria = await this.criteriaRepo.listByEntity("pbi", pbiId, client); + const configuration = await this.rulesProvider.getCurrentPbiConfiguration(client); return this.buildCompletenessReport(pbi, this.buildDetailedReport(pbi, criteria, configuration.vague_terms ?? TERMOS_VAGOS_PADRAO), configuration); } diff --git a/backend/src/modules/quality/quality.types.ts b/backend/src/modules/quality/quality.types.ts index 962111b..da5c3e4 100644 --- a/backend/src/modules/quality/quality.types.ts +++ b/backend/src/modules/quality/quality.types.ts @@ -1,3 +1,4 @@ +import type { PoolClient } from "pg"; import type { Pbi } from "../pbis/pbis.types.js"; export const ENTITY_TYPES = ["epico", "feature", "pbi"] as const; @@ -44,7 +45,7 @@ export interface PbiQualityConfigurationRecord extends PbiQualityConfigurationIn } export interface QualityRuleConfigurationProvider { - getCurrentPbiConfiguration(): Promise; + getCurrentPbiConfiguration(client?: PoolClient): Promise; } export interface QualityReport { diff --git a/backend/src/modules/repo-analyses/repo-analyses.dispatch.db.test.ts b/backend/src/modules/repo-analyses/repo-analyses.dispatch.db.test.ts new file mode 100644 index 0000000..dda0be2 --- /dev/null +++ b/backend/src/modules/repo-analyses/repo-analyses.dispatch.db.test.ts @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { Pool } from 'pg'; +import { validateTarget } from '../../database/seed-lib'; +import { RepoAnalysesRepository } from './repo-analyses.repository'; +import { ProjectArchiveRepository } from '../projects/projects.archive'; + +test('solicitação idempotente, lease exclusiva e bloqueio com arquivamento', { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async () => { + const db = new Pool({ connectionString: validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, 'test') }); + const user = randomUUID(), project = randomUUID(), other = randomUUID(), concurrent = randomUUID(); + try { + await db.query("INSERT INTO usuario(id,nome,email,senha_hash,role) VALUES($1,'Teste',$2,'hash','po')", [user, user+'@test.local']); + for (const id of [project, other, concurrent]) await db.query("INSERT INTO projeto(id,nome,cliente) VALUES($1::uuid,$1::text,'Teste')",[id]); + const repository = new RepoAnalysesRepository(db); + const input = { projetoId: project, usuarioId: user, repositorioUrl: 'https://github.com/acme/repo', runId: randomUUID(), requestKey: randomUUID(), profile: 'quick' }; + const [a,b] = await Promise.all([repository.create(input),repository.create(input)]); + assert.equal(a.id,b.id); assert.equal(a.dispatch_pending,true); + await assert.rejects(repository.create({...input, requestKey: randomUUID()}), /já possui/); + await assert.rejects(new ProjectArchiveRepository(db).archive(project), /cancele/); + const leases = await Promise.all([repository.claimDispatch(), repository.claimDispatch()]); + const own = leases.filter(row=>row?.id === a.id); + assert.equal(own.length,1); + await repository.finishDispatch({...own[0]!, dispatch_lease:randomUUID()}); + assert.equal((await repository.findById(a.id,project))!.dispatch_pending,true); + await repository.finishDispatch(own[0]!); + assert.equal((await repository.findById(a.id,project))!.dispatch_pending,false); + await db.query("UPDATE analise_repositorio SET status='pausada' WHERE id=$1",[a.id]); + const resumed = await repository.queueResume(a.id, project); + assert.equal(resumed?.resume_requested,true); + assert.equal(resumed?.dispatch_pending,true); + const stale = { status: 'falha', etapa: 'error', etapaLabel: 'Resposta antiga', progresso: 0 }; + await repository.updateStatus(a.run_id, stale, a.revision ?? 0); + assert.equal((await repository.findById(a.id,project))?.status,'iniciado'); + // Even after dispatch finishes, an earlier HTTP response cannot overwrite the resumed run. + await db.query('UPDATE analise_repositorio SET dispatch_pending=FALSE WHERE id=$1',[a.id]); + await repository.updateStatus(a.run_id, stale, a.revision ?? 0); + assert.equal((await repository.findById(a.id,project))?.status,'iniciado'); + const fresh = (await repository.findById(a.id,project))!; + await repository.updateStatus(a.run_id, { ...stale, status: 'em_execucao' }, fresh.revision!); + assert.equal((await repository.findById(a.id,project))?.status,'em_execucao'); + await db.query('UPDATE analise_repositorio SET dispatch_pending=TRUE WHERE id=$1',[a.id]); + await assert.rejects(new ProjectArchiveRepository(db).archive(project), /cancele/); + const cancelling = await repository.cancelPending(a.id,project); + assert.equal(cancelling?.status,'cancelando'); + assert.equal(cancelling?.dispatch_pending,true); + await new ProjectArchiveRepository(db).archive(other); + await assert.rejects(repository.create({...input,projetoId:other,requestKey:randomUUID()}), /arquivado/); + const race = await Promise.allSettled([ + repository.create({...input,projetoId:concurrent,requestKey:randomUUID(),runId:randomUUID()}), + new ProjectArchiveRepository(db).archive(concurrent), + ]); + assert.equal(race.filter(result=>result.status==='fulfilled').length,1); + const rows = await repository.findByProjectId(concurrent); + const status = (await db.query('SELECT status FROM projeto WHERE id=$1',[concurrent])).rows[0].status; + assert.equal(rows.length, status==='arquivado' ? 0 : 1); + if (rows.length) { + const cancelled = await repository.cancelPending(rows[0].id,concurrent); + assert.equal(cancelled?.status,'cancelada'); + assert.equal(cancelled?.dispatch_pending,false); + await new ProjectArchiveRepository(db).archive(concurrent); + } + } finally { + await db.query('DELETE FROM projeto WHERE id=ANY($1::uuid[])',[[project,other,concurrent]]); + await db.query('DELETE FROM usuario WHERE id=$1',[user]); await db.end(); + } +}); diff --git a/backend/src/modules/repo-analyses/repo-analyses.recovery.test.ts b/backend/src/modules/repo-analyses/repo-analyses.recovery.test.ts new file mode 100644 index 0000000..71b51c3 --- /dev/null +++ b/backend/src/modules/repo-analyses/repo-analyses.recovery.test.ts @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import axios from 'axios'; +import { RepoAnalysesService } from './repo-analyses.service'; +import { RepoAnalysesRepository } from './repo-analyses.repository'; +import type { RepoAnalysisRecord } from './repo-analyses.types'; + +test('recupera relatório em consulta posterior à conclusão após falha transitória', async t => { + const record = { id: 'a', projeto_id: 'p', run_id: 'r', status: 'em_execucao' } as RepoAnalysisRecord; + let reports = 0; + t.mock.method(axios, 'get', async (url: string) => { + if (url.endsWith('/report')) { + if (++reports === 1) throw new Error('network'); + return { data: '# Relatório recuperado' }; + } + return { data: { status: 'completed', stage: 'done' } }; + }); + const repository = { + findById: async () => record, + updateStatus: async (_: string, update: { status: string; relatorioMarkdown?: string }) => { + Object.assign(record, { status: update.status, relatorio_markdown: update.relatorioMarkdown }); + }, + } as unknown as RepoAnalysesRepository; + const service = new RepoAnalysesService(repository); + await service.getById('p', 'a'); + assert.equal(record.status, 'concluido'); + assert.equal(record.relatorio_markdown, undefined); + await service.getById('p', 'a'); + assert.equal(record.relatorio_markdown, '# Relatório recuperado'); + await service.getById('p', 'a'); + assert.equal(reports, 2); +}); + +test('falha ao persistir solicitação não inicia trabalho no Python', async t => { + const post = t.mock.method(axios, 'post', async () => { throw new Error('não deveria enviar'); }); + const repository = { create: async () => { throw new Error('DB unavailable'); } } as unknown as RepoAnalysesRepository; + await assert.rejects(new RepoAnalysesService(repository).startAnalysis('p', 'u', 'https://github.com/acme/repo'), /DB unavailable/); + assert.equal(post.mock.callCount(), 0); +}); + +test('retry de despacho após resposta perdida mantém o identificador da execução', async t => { + const record = { id: 'a', run_id: 'stable-id', repositorio_url: 'https://github.com/acme/repo', perfil: 'quick' }; + const sent: unknown[] = []; + let attempts = 0; + t.mock.method(axios, 'post', async (_url: string, body: unknown) => { + sent.push(body); + if (++attempts === 1) throw new Error('response lost'); + return { data: { run_id: record.run_id } }; + }); + const results: (string | undefined)[] = []; + const repository = { + claimDispatch: async () => record, + findById: async () => record, + finishDispatch: async (_record: unknown, error?: string) => { results.push(error); }, + findSyncable: async () => [], + } as unknown as RepoAnalysesRepository; + const service = new RepoAnalysesService(repository); + await service.processQueue(); await service.processQueue(); + assert.deepEqual(sent[0], sent[1]); + assert.ok(results[0]); assert.equal(results[1], undefined); +}); diff --git a/backend/src/modules/repo-analyses/repo-analyses.repository.ts b/backend/src/modules/repo-analyses/repo-analyses.repository.ts index d0325dd..76a0926 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.repository.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.repository.ts @@ -1,21 +1,92 @@ -import { pool } from '../../database/db'; // Ajuste conforme a conexão do seu projeto +import { randomUUID } from 'node:crypto'; +import { Pool } from 'pg'; +import { pool } from '../../database/db'; +import { withTransaction } from '../../database/transaction'; +import { assertWritable, lockHierarchy } from '../projects/hierarchy-archive'; +import { AppError, ConflictError } from '../../shared/errors'; import { RepoAnalysisRecord } from './repo-analyses.types'; export class RepoAnalysesRepository { - async create(data: { - projetoId: string; - usuarioId: string; - repositorioUrl: string; - runId: string; - }): Promise { - const query = ` - INSERT INTO analise_repositorio (projeto_id, usuario_id, repositorio_url, run_id, status, etapa, etapa_label, progresso) - VALUES ($1, $2, $3, $4, 'iniciado', 'ollama', 'Iniciando conexão...', 0) - RETURNING *; - `; - const values = [data.projetoId, data.usuarioId, data.repositorioUrl, data.runId]; - const result = await pool.query(query, values); - return result.rows[0]; + constructor(private readonly db: Pool = pool) {} + + async create(data: { projetoId: string; usuarioId: string; repositorioUrl: string; + runId: string; requestKey: string; profile: string }): Promise { + return withTransaction(this.db, async client => { + // Serialize a user's quota check, then use the same project lock as archive. + await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [data.usuarioId]); + await lockHierarchy(client, 'projeto', data.projetoId); + const existing = (await client.query( + 'SELECT * FROM analise_repositorio WHERE projeto_id=$1 AND usuario_id=$2 AND request_key=$3', + [data.projetoId, data.usuarioId, data.requestKey])).rows[0]; + if (existing) { + if (existing.repositorio_url !== data.repositorioUrl || existing.perfil !== data.profile) + throw new ConflictError('Chave de solicitação já utilizada com outro conteúdo.'); + return existing; + } + await assertWritable(client, 'projeto', data.projetoId); + const active = await client.query( + "SELECT projeto_id FROM analise_repositorio WHERE (usuario_id=$1 OR projeto_id=$2) AND status NOT IN ('concluido','falha','cancelada')", + [data.usuarioId, data.projetoId]); + if (active.rows.some(row => row.projeto_id === data.projetoId)) + throw new ConflictError('Este projeto já possui uma análise ativa. Conclua ou cancele antes de iniciar outra.'); + if (active.rowCount! >= 3) throw new AppError('Limite de três análises ativas por usuário.', 429, 'ANALYSIS_LIMIT'); + return (await client.query( + `INSERT INTO analise_repositorio + (projeto_id, usuario_id, repositorio_url, run_id, request_key, perfil, dispatch_pending, status, etapa, etapa_label, progresso) + VALUES ($1,$2,$3,$4,$5,$6,TRUE,'iniciado','queued','Na fila',0) RETURNING *`, + [data.projetoId, data.usuarioId, data.repositorioUrl, data.runId, data.requestKey, data.profile])).rows[0]; + }); + } + + async claimDispatch(): Promise { + const result = await this.db.query(`UPDATE analise_repositorio SET dispatch_lease=$1, + dispatch_after=NOW()+INTERVAL '60 seconds', dispatch_attempts=dispatch_attempts+1 + WHERE id=(SELECT id FROM analise_repositorio WHERE dispatch_pending AND dispatch_after<=NOW() + ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`, [randomUUID()]); + return result.rows[0] ?? null; + } + + async cancelPending(id: string, projetoId: string): Promise { + return (await this.db.query(`UPDATE analise_repositorio SET cancel_requested=TRUE, + status=CASE WHEN dispatch_attempts=0 THEN 'cancelada' ELSE 'cancelando' END, + dispatch_pending=dispatch_attempts>0, updated_at=NOW() + WHERE id=$1 AND projeto_id=$2 AND dispatch_pending RETURNING *`, [id, projetoId])).rows[0] ?? null; + } + + async queueResume(id: string, projetoId: string): Promise { + const record = await this.findById(id, projetoId); + if (!record) return null; + return withTransaction(this.db, async client => { + await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [record.usuario_id]); + await lockHierarchy(client, 'projeto', projetoId); + await assertWritable(client, 'projeto', projetoId); + const current = (await client.query('SELECT * FROM analise_repositorio WHERE id=$1 FOR UPDATE', [id])).rows[0]; + if (current.resume_requested) return current; + if (current.status !== 'pausada' && !(current.status === 'falha' && current.metadados?.can_resume)) + throw new ConflictError('A análise não possui progresso retomável.'); + const active = await client.query("SELECT projeto_id FROM analise_repositorio WHERE id<>$3 AND (usuario_id=$1 OR projeto_id=$2) AND status NOT IN ('concluido','falha','cancelada')", [record.usuario_id, projetoId, id]); + if (active.rows.some(row => row.projeto_id === projetoId) || active.rowCount! >= 3) + throw new ConflictError('Limite de análises ativas atingido.'); + return (await client.query(`UPDATE analise_repositorio SET resume_requested=TRUE, cancel_requested=FALSE, + dispatch_pending=TRUE, dispatch_after=NOW(), status='iniciado', concluido_em=NULL, erro=NULL WHERE id=$1 RETURNING *`, [id])).rows[0]; + }); + } + + async finishDispatch(record: RepoAnalysisRecord, error?: string, permanent = false, cancelled = false): Promise { + await this.db.query(`UPDATE analise_repositorio SET dispatch_pending=$3 OR (cancel_requested AND NOT $6 AND NOT $4), + dispatch_lease=NULL, dispatch_after=NOW()+make_interval(secs => LEAST(300, dispatch_attempts*10)), + resume_requested=CASE WHEN $3 THEN resume_requested ELSE FALSE END, + status=CASE WHEN $4 THEN 'falha' ELSE status END, + etapa_label=CASE WHEN $4 THEN 'Falha ao iniciar' ELSE etapa_label END, + erro=$5, updated_at=NOW() + WHERE id=$1 AND dispatch_lease=$2`, + [record.id, record.dispatch_lease, Boolean(error) && !permanent, permanent, error ?? null, cancelled]); + } + + async findSyncable(): Promise { + return (await this.db.query(`SELECT * FROM analise_repositorio WHERE NOT dispatch_pending AND + (status IN ('iniciado','em_execucao','pausando','cancelando') OR + (status='concluido' AND relatorio_markdown IS NULL)) ORDER BY updated_at LIMIT 20`)).rows; } async findById(id: string, projetoId: string): Promise { @@ -25,7 +96,7 @@ export class RepoAnalysesRepository { LEFT JOIN usuario u ON ar.usuario_id = u.id WHERE ar.id = $1 AND ar.projeto_id = $2; `; - const result = await pool.query(query, [id, projetoId]); + const result = await this.db.query(query, [id, projetoId]); return result.rows[0] || null; } @@ -37,7 +108,7 @@ export class RepoAnalysesRepository { WHERE ar.projeto_id = $1 ORDER BY ar.created_at DESC; `; - const result = await pool.query(query, [projetoId]); + const result = await this.db.query(query, [projetoId]); return result.rows; } @@ -52,7 +123,8 @@ export class RepoAnalysesRepository { erro?: string; relatorioMarkdown?: string; metadados?: any; - } + }, + expectedRevision: number, ): Promise { const query = ` UPDATE analise_repositorio @@ -65,10 +137,10 @@ export class RepoAnalysesRepository { relatorio_markdown = COALESCE($7::text, relatorio_markdown), metadados = COALESCE($8::jsonb, metadados), updated_at = NOW(), - concluido_em = CASE WHEN $1::varchar IN ('concluido', 'falha', 'cancelada') THEN NOW() ELSE concluido_em END - WHERE run_id = $9::varchar; + concluido_em = CASE WHEN $1::varchar IN ('concluido', 'falha', 'cancelada') THEN COALESCE(concluido_em, NOW()) ELSE concluido_em END + WHERE run_id = $9::varchar AND revision = $10 AND NOT dispatch_pending; `; - await pool.query(query, [ + await this.db.query(query, [ statusData.status, statusData.etapa, statusData.etapaLabel, @@ -78,6 +150,7 @@ export class RepoAnalysesRepository { statusData.relatorioMarkdown || null, statusData.metadados ? JSON.stringify(statusData.metadados) : null, runId, + expectedRevision, ]); } } diff --git a/backend/src/modules/repo-analyses/repo-analyses.routes.ts b/backend/src/modules/repo-analyses/repo-analyses.routes.ts index 1ca099d..af79263 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.routes.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.routes.ts @@ -62,7 +62,9 @@ export function createRepoAnalysesRouter( if (!['quick', 'balanced', 'complete'].includes(profile)) { throw new ValidationError('Perfil inválido. Use quick, balanced ou complete.'); } - const analysis = await service.startAnalysis(String(req.params.projectId), req.auth?.id ?? '', url, profile); + const requestKey = req.get('Idempotency-Key'); + if (requestKey) validateUuid(requestKey, 'Chave da solicitação'); + const analysis = await service.startAnalysis(String(req.params.projectId), req.auth?.id ?? '', url, profile, requestKey); res.status(201).json(analysis); } catch (error) { next(error); diff --git a/backend/src/modules/repo-analyses/repo-analyses.service.test.ts b/backend/src/modules/repo-analyses/repo-analyses.service.test.ts index 1c0cabf..cf99254 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.service.test.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.service.test.ts @@ -18,7 +18,7 @@ test('mapeia os estados reais do Analyzer para o contrato persistido pela API', }); test('consulta análise vinculada ao projeto da rota, não apenas por ID', async () => { - const record = { id: 'analysis-1', projeto_id: 'project-a', status: 'concluido', run_id: 'run-1' }; + const record = { id: 'analysis-1', projeto_id: 'project-a', status: 'concluido', run_id: 'run-1', relatorio_markdown: 'Relatório' }; const repository = { findById: async (id: string, projectId: string) => id === record.id && projectId === record.projeto_id ? record : null, } as RepoAnalysesRepository; diff --git a/backend/src/modules/repo-analyses/repo-analyses.service.ts b/backend/src/modules/repo-analyses/repo-analyses.service.ts index 32ae703..ae0943f 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.service.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.service.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto'; +import { serviceHeaders } from "../../shared/service-auth.js"; import axios from 'axios'; import { AppError, ValidationError } from '../../shared/errors'; import { env } from '../../config/env'; @@ -49,29 +51,43 @@ export class RepoAnalysesService { return githubRegex.test(url.trim()); } - async startAnalysis(projetoId: string, usuarioId: string, repositorioUrl: string, profile: 'quick' | 'balanced' | 'complete' = 'quick'): Promise { + async startAnalysis(projetoId: string, usuarioId: string, repositorioUrl: string, profile: 'quick' | 'balanced' | 'complete' = 'quick', requestKey: string = randomUUID()): Promise { if (!this.validateGithubUrl(repositorioUrl)) { throw new ValidationError('URL do repositório GitHub inválida. Utilize o formato https://github.com/usuario/repositorio'); } - // Dispara a execução no ai-service / RepoAnalyzer - try { - const response = await axios.post(`${this.analyzerBaseUrl}/api/analyze`, { url: repositorioUrl, profile }, { timeout: 15_000 }); - - const { run_id } = response.data; - - const record = await this.repository.create({ - projetoId, - usuarioId, - repositorioUrl, - runId: run_id, - }); + return this.repository.create({ projetoId, usuarioId, repositorioUrl: repositorioUrl.trim(), + runId: randomUUID(), requestKey, profile }); + } - return record; - } catch (error: unknown) { - const detail = axios.isAxiosError(error) && typeof error.response?.data?.detail === 'string' ? error.response.data.detail : 'serviço indisponível'; - throw new AppError(`Falha ao iniciar análise no motor de IA: ${detail}`, 503, 'ANALYZER_UNAVAILABLE'); + async processQueue(): Promise { + const record = await this.repository.claimDispatch(); + if (record) { + try { + const response = await axios.post(`${this.analyzerBaseUrl}/api/analyze`, + { url: record.repositorio_url, profile: record.perfil, run_id: record.run_id }, + { headers: serviceHeaders(), timeout: 15_000 }); + if (response.data.run_id !== record.run_id) throw new Error('Identidade da execução divergente'); + const latest = await this.repository.findById(record.id, record.projeto_id); + if (latest?.cancel_requested) { + // Repeated cancel is safe: Python returns the terminal state when already cancelled. + await axios.post(`${this.analyzerBaseUrl}/api/runs/${record.run_id}/cancel`, {}, { headers: serviceHeaders(), timeout: 15_000 }); + } else if (latest?.resume_requested) { + await axios.post(`${this.analyzerBaseUrl}/api/runs/${record.run_id}/resume`, {}, { headers: serviceHeaders(), timeout: 15_000 }); + } + await this.repository.finishDispatch(record, undefined, false, latest?.cancel_requested); + } catch (error) { + const code = axios.isAxiosError(error) ? error.response?.status : undefined; + await this.repository.finishDispatch(record, 'Não foi possível despachar a análise; aguardando nova tentativa.', + code !== undefined && [400, 409, 422].includes(code)); + } } + for (const analysis of await this.repository.findSyncable()) await this.syncAnalysisStatus(analysis); + } + + private needsSync(analysis: RepoAnalysisRecord): boolean { + return !analysis.dispatch_pending && (ACTIVE_STATUSES.includes(analysis.status) || + (analysis.status === 'concluido' && !analysis.relatorio_markdown)); } async listByProject(projetoId: string): Promise { @@ -79,16 +95,16 @@ export class RepoAnalysesService { // Opcional: Atualizar status das análises em andamento consultando o ai-service await Promise.all(analyses - .filter((analysis) => ACTIVE_STATUSES.includes(analysis.status)) - .map((analysis) => this.syncAnalysisStatus(analysis.run_id))); + .filter((analysis) => this.needsSync(analysis)) + .map((analysis) => this.syncAnalysisStatus(analysis))); return await this.repository.findByProjectId(projetoId); } async getById(projetoId: string, id: string): Promise { const analysis = await this.repository.findById(id, projetoId); - if (analysis && ACTIVE_STATUSES.includes(analysis.status)) { - await this.syncAnalysisStatus(analysis.run_id); + if (analysis && this.needsSync(analysis)) { + await this.syncAnalysisStatus(analysis); return await this.repository.findById(id, projetoId); } return analysis; @@ -97,23 +113,29 @@ export class RepoAnalysesService { async controlAnalysis(projetoId: string, id: string, action: 'pause' | 'resume' | 'cancel'): Promise { const analysis = await this.repository.findById(id, projetoId); if (!analysis) return null; + if (action === 'resume') return this.repository.queueResume(id, projetoId); + if (analysis.dispatch_pending && action === 'cancel') { + const cancelled = await this.repository.cancelPending(id, projetoId); + if (cancelled) return cancelled; + } else if (analysis.dispatch_pending) throw new AppError('A análise aguarda despacho. Tente novamente em instantes.', 409, 'ANALYSIS_QUEUED'); if (!analysis.run_id) throw new AppError('Esta análise não possui execução retomável.', 409, 'ANALYSIS_NOT_RESUMABLE'); try { - await axios.post(`${this.analyzerBaseUrl}/api/runs/${encodeURIComponent(analysis.run_id)}/${action}`, {}, { timeout: 15_000 }); - await this.syncAnalysisStatus(analysis.run_id); + await axios.post(`${this.analyzerBaseUrl}/api/runs/${encodeURIComponent(analysis.run_id)}/${action}`, {}, { headers: serviceHeaders(), timeout: 15_000 }); + await this.syncAnalysisStatus(analysis); return await this.repository.findById(id, projetoId); } catch (error: unknown) { const status = axios.isAxiosError(error) ? error.response?.status : undefined; const detail = axios.isAxiosError(error) && typeof error.response?.data?.detail === 'string' ? error.response.data.detail : 'serviço indisponível'; if (status === 409) throw new AppError(detail, 409, 'ANALYSIS_CONTROL_CONFLICT'); - throw new AppError(`Falha ao ${action === 'pause' ? 'pausar' : action === 'resume' ? 'retomar' : 'cancelar'} análise: ${detail}`, 503, 'ANALYZER_UNAVAILABLE'); + throw new AppError(`Falha ao ${action === 'pause' ? 'pausar' : 'cancelar'} análise: ${detail}`, 503, 'ANALYZER_UNAVAILABLE'); } } - private async syncAnalysisStatus(runId: string): Promise { + private async syncAnalysisStatus(analysis: RepoAnalysisRecord): Promise { + const runId = analysis.run_id; try { - const response = await axios.get(`${this.analyzerBaseUrl}/api/runs/${runId}`, { timeout: 15_000 }); + const response = await axios.get(`${this.analyzerBaseUrl}/api/runs/${runId}`, { headers: serviceHeaders(), timeout: 15_000 }); const data = response.data; const status = mapAnalyzerStatus(data.status); const etapa = (data.stage || 'queued') as RepoAnalysisStep; @@ -122,8 +144,9 @@ export class RepoAnalysesService { let relatorioMarkdown = undefined; if (status === 'concluido') { try { - const reportRes = await axios.get(`${this.analyzerBaseUrl}/api/runs/${runId}/report`, { timeout: 15_000 }); - relatorioMarkdown = reportRes.data.report || reportRes.data; + const reportRes = await axios.get(`${this.analyzerBaseUrl}/api/runs/${runId}/report`, { headers: serviceHeaders(), timeout: 15_000 }); + const report = typeof reportRes.data === 'string' ? reportRes.data : reportRes.data.report; + if (typeof report === 'string' && report.trim()) relatorioMarkdown = report; } catch (e) { // Ignora se o relatório ainda não estiver pronto } @@ -138,7 +161,7 @@ export class RepoAnalysesService { erro: data.error, relatorioMarkdown, metadados: stats, - }); + }, analysis.revision ?? 0); } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 404) { await this.repository.updateStatus(runId, { @@ -148,10 +171,26 @@ export class RepoAnalysesService { progresso: 0, mensagem: 'O motor de análise não encontrou esta execução. Inicie uma nova análise para continuar.', erro: 'Execução não encontrada no serviço de análise.', - }); + }, analysis.revision ?? 0); return; } console.warn('[RepoAnalyzer] Falha ao sincronizar o status de uma análise; nova tentativa na próxima consulta.'); } } } + +export function startRepoAnalysesWorker(): () => void { + const service = new RepoAnalysesService(); + let busy = false; + const tick = async () => { + if (busy) return; + busy = true; + try { await service.processQueue(); } + catch { console.warn('[RepoAnalyzer] Falha no worker; a solicitação durável será retomada.'); } + finally { busy = false; } + }; + const timer = setInterval(() => void tick(), 5000); + timer.unref(); + void tick(); + return () => clearInterval(timer); +} diff --git a/backend/src/modules/repo-analyses/repo-analyses.types.ts b/backend/src/modules/repo-analyses/repo-analyses.types.ts index d628d32..910a98b 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.types.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.types.ts @@ -2,6 +2,7 @@ export type RepoAnalysisStatus = 'iniciado' | 'em_execucao' | 'pausando' | 'paus export type RepoAnalysisStep = 'ollama' | 'clone' | 'scan' | 'files' | 'synthesis' | 'done' | 'error' | 'queued'; export interface RepoAnalysisRecord { + revision?: number; id: string; projeto_id: string; usuario_id: string; @@ -20,6 +21,12 @@ export interface RepoAnalysisRecord { concluido_em?: string; autor_nome?: string; autor_email?: string; + dispatch_pending?: boolean; + dispatch_attempts?: number; + dispatch_lease?: string; + cancel_requested?: boolean; + resume_requested?: boolean; + perfil?: 'quick' | 'balanced' | 'complete'; } export interface StartAnalysisDTO { 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..0eefdec --- /dev/null +++ b/backend/src/modules/search/search.service.ts @@ -0,0 +1,70 @@ +import axios from "axios"; +import { env } from "../../config/env.js"; +import { AppError, NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; +import { serviceHeaders } from "../../shared/service-auth.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, headers: serviceHeaders() }); + 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/backend/src/modules/technologies/technologies.controller.ts b/backend/src/modules/technologies/technologies.controller.ts new file mode 100644 index 0000000..fdef099 --- /dev/null +++ b/backend/src/modules/technologies/technologies.controller.ts @@ -0,0 +1,14 @@ +import type { NextFunction, Request, Response } from "express"; +import { TechnologiesRepository } from "./technologies.repository.js"; + +export class TechnologiesController { + constructor(private readonly repository: Pick = new TechnologiesRepository()) {} + + list = async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + res.json({ items: await this.repository.list() }); + } catch (error) { + next(error); + } + }; +} diff --git a/backend/src/modules/technologies/technologies.repository.ts b/backend/src/modules/technologies/technologies.repository.ts new file mode 100644 index 0000000..be68896 --- /dev/null +++ b/backend/src/modules/technologies/technologies.repository.ts @@ -0,0 +1,13 @@ +import type { Pool } from "pg"; +import { pool } from "../../database/db.js"; + +export class TechnologiesRepository { + constructor(private readonly db: Pick = pool) {} + + async list(): Promise> { + const { rows } = await this.db.query<{ id: string; nome: string }>( + "SELECT id, nome FROM tecnologia ORDER BY nome ASC, id ASC", + ); + return rows; + } +} diff --git a/backend/src/modules/technologies/technologies.routes.ts b/backend/src/modules/technologies/technologies.routes.ts index 1830e18..7c5c712 100644 --- a/backend/src/modules/technologies/technologies.routes.ts +++ b/backend/src/modules/technologies/technologies.routes.ts @@ -1,21 +1,13 @@ -import { Router, type NextFunction, type Request, type Response } from "express"; +import { Router } from "express"; import type { Pool } from "pg"; import { pool } from "../../database/db.js"; +import { TechnologiesRepository } from "./technologies.repository.js"; +import { TechnologiesController } from "./technologies.controller.js"; export function createTechnologiesRouter(db: Pick = pool) { const router = Router(); - - router.get("/", async (_req: Request, res: Response, next: NextFunction) => { - try { - const result = await db.query<{ id: string; nome: string }>( - "SELECT id, nome FROM tecnologia ORDER BY nome ASC, id ASC", - ); - res.json({ items: result.rows }); - } catch (error) { - next(error); - } - }); - + const controller = new TechnologiesController(new TechnologiesRepository(db)); + router.get("/", controller.list); return router; } diff --git a/backend/src/shared/service-auth.ts b/backend/src/shared/service-auth.ts new file mode 100644 index 0000000..272cb5c --- /dev/null +++ b/backend/src/shared/service-auth.ts @@ -0,0 +1,4 @@ +import { env } from "../config/env.js"; +export function serviceHeaders(): Record { + return { "X-Service-Token": env.AI_SERVICE_TOKEN }; +} 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_chat_and_ingestion.sql b/database/migrations/014_chat_and_ingestion.sql new file mode 100644 index 0000000..f0a1e93 --- /dev/null +++ b/database/migrations/014_chat_and_ingestion.sql @@ -0,0 +1,11 @@ +ALTER TABLE mensagem ADD COLUMN IF NOT EXISTS processing_status TEXT NOT NULL DEFAULT 'completed' + CHECK (processing_status IN ('pending', 'completed', 'failed')); +CREATE INDEX IF NOT EXISTS idx_mensagem_pending ON mensagem(conversa_id) WHERE processing_status = 'pending'; + +ALTER TABLE documento ADD COLUMN IF NOT EXISTS ingest_attempts INTEGER NOT NULL DEFAULT 0; +ALTER TABLE documento ADD COLUMN IF NOT EXISTS ingest_next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP; +ALTER TABLE documento ADD COLUMN IF NOT EXISTS ingest_lease UUID; +ALTER TABLE documento ADD COLUMN IF NOT EXISTS ingest_locked_until TIMESTAMPTZ; +ALTER TABLE documento ADD COLUMN IF NOT EXISTS ingest_error TEXT; +CREATE INDEX IF NOT EXISTS idx_documento_ingestion ON documento(ingest_next_attempt_at) + WHERE status_processamento <> 'processado'; 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/015_ingestion_diagnostics.sql b/database/migrations/015_ingestion_diagnostics.sql new file mode 100644 index 0000000..cbbeed3 --- /dev/null +++ b/database/migrations/015_ingestion_diagnostics.sql @@ -0,0 +1,2 @@ +ALTER TABLE documento ADD COLUMN IF NOT EXISTS ingest_error_code TEXT; +ALTER TABLE documento ADD COLUMN IF NOT EXISTS ingest_retryable BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/database/migrations/016_analysis_dispatch.sql b/database/migrations/016_analysis_dispatch.sql new file mode 100644 index 0000000..501f2fe --- /dev/null +++ b/database/migrations/016_analysis_dispatch.sql @@ -0,0 +1,11 @@ +-- Existing runs have already been dispatched; only new durable requests are queued. +ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS dispatch_pending BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS dispatch_attempts INTEGER NOT NULL DEFAULT 0; +ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS dispatch_after TIMESTAMPTZ NOT NULL DEFAULT NOW(); +ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS dispatch_lease UUID; +ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS perfil VARCHAR(20) NOT NULL DEFAULT 'quick'; +ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS request_key UUID; +ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS cancel_requested BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS resume_requested BOOLEAN NOT NULL DEFAULT FALSE; +CREATE UNIQUE INDEX IF NOT EXISTS idx_analysis_request_key ON analise_repositorio(projeto_id, usuario_id, request_key); +CREATE INDEX IF NOT EXISTS idx_analysis_dispatch ON analise_repositorio(dispatch_after) WHERE dispatch_pending; 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/migrations/017_safe_user_role.sql b/database/migrations/017_safe_user_role.sql new file mode 100644 index 0000000..950a61c --- /dev/null +++ b/database/migrations/017_safe_user_role.sql @@ -0,0 +1,2 @@ +-- Preserve existing accounts; only omitted roles in new accounts default to dev. +ALTER TABLE usuario ALTER COLUMN role SET DEFAULT 'dev'; diff --git a/database/migrations/018_analysis_revision.sql b/database/migrations/018_analysis_revision.sql new file mode 100644 index 0000000..093fbc3 --- /dev/null +++ b/database/migrations/018_analysis_revision.sql @@ -0,0 +1,9 @@ +ALTER TABLE analise_repositorio ADD COLUMN revision INTEGER NOT NULL DEFAULT 0; +CREATE FUNCTION increment_analysis_revision() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + NEW.revision := OLD.revision + 1; + RETURN NEW; +END; +$$; +CREATE TRIGGER analysis_revision BEFORE UPDATE ON analise_repositorio +FOR EACH ROW EXECUTE FUNCTION increment_analysis_revision(); 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..efc3525 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,8 @@ services: environment: - N8N_PORT=5678 + # Supports the base64 envelope for uploads up to the backend maximum (100 MiB). + - N8N_PAYLOAD_SIZE_MAX=150 - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY:-sinapse-shared-dev-encryption-key-2026} - GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-America/Sao_Paulo} - TZ=${TZ:-America/Sao_Paulo} @@ -59,6 +61,12 @@ services: # Ollama executando no Windows - OLLAMA_BASE_URL=${OLLAMA_DOCKER_BASE_URL:-http://host.docker.internal:11434} + # Tokens usados pelas chamadas HTTP do workflow (via $env nas expressoes + # do n8n). AI_SERVICE_TOKEN autentica em /chunk e /embed do ai-service; + # N8N_INGEST_TOKEN autentica em POST /documents/.../chunks do backend. + - AI_SERVICE_TOKEN=${AI_SERVICE_TOKEN:?Configure AI_SERVICE_TOKEN in .env} + - N8N_INGEST_TOKEN=${N8N_INGEST_TOKEN:-sinapse-dev-ingest-token} + # PostgreSQL executando no Docker - DB_TYPE=postgresdb - DB_POSTGRESDB_HOST=postgres @@ -69,7 +77,7 @@ services: volumes: - n8n_data:/home/node/.n8n - - ./n8n/local-files:/files + - ./storage:/files depends_on: postgres: @@ -78,6 +86,27 @@ services: networks: - sinapse-network + # Ollama no Docker (profile opcional). AGENTS.md padrao e rodar Ollama no + # host; este servico ativa somente quando o perfil local-ai e selecionado. + ollama: + profiles: [local-ai] + image: ollama/ollama:latest + container_name: sinapse-ollama + restart: unless-stopped + ports: + - "127.0.0.1:${OLLAMA_PORT:-11434}:11434" + volumes: + - ollama_data:/root/.ollama + environment: + - OLLAMA_KEEP_ALIVE=${OLLAMA_KEEP_ALIVE:-24h} + healthcheck: + test: ["CMD-SHELL", "ollama list || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - sinapse-network backend: build: @@ -103,16 +132,29 @@ 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} - + - AI_SERVICE_TOKEN=${AI_SERVICE_TOKEN:?Configure AI_SERVICE_TOKEN in .env} - DOCUMENT_MAX_SIZE_MB=${DOCUMENT_MAX_SIZE_MB:-20} - - DOCUMENT_STORAGE_DIR=/data/documents + # /files e bind-mount compartilhado com o container do n8n (./storage no host), + # pre-requisito para o workflow de debug manual do n8n enxergar o arquivo. + # O fluxo canonico (worker DocumentIngestionWorker) le o arquivo direto do + # storage e chama o ai-service, sem passar pelo volume do n8n. + - DOCUMENT_STORAGE_DIR=/files - DOCUMENT_EVENTS_WEBHOOK_URL=${DOCUMENT_EVENTS_WEBHOOK_URL:-} + # Vazio por padrao: HttpDocumentIngestionClient usa ${AI_SERVICE_URL}/documents/process + # (fluxo canonico da S2-01, com retry/lease). Preencha para redirecionar. + - DOCUMENT_INGEST_WEBHOOK_URL=${DOCUMENT_INGEST_WEBHOOK_URL:-} + - SEARCH_MIN_VECTOR_SIMILARITY=${SEARCH_MIN_VECTOR_SIMILARITY:-0.55} + - SEARCH_MIN_TEXT_RANK=${SEARCH_MIN_TEXT_RANK:-0.05} + # Token server-to-server para o backend aceitar o POST .../documents/.../chunks + # vindo do workflow do n8n (quando usado como ferramenta de debug manual). + - N8N_INGEST_TOKEN=${N8N_INGEST_TOKEN:-sinapse-dev-ingest-token} - MIGRATIONS_DIR=/database/migrations - INIT_SQL_PATH=/database/init.sql volumes: - - documents_data:/data/documents + - ./storage:/files - ./database/migrations:/database/migrations:ro - ./database/init.sql:/database/init.sql:ro - ./database/seed:/database/seed:ro @@ -133,11 +175,18 @@ services: container_name: sinapse-ai-service restart: unless-stopped + # Limites de recurso herdados da S2-01 para proteger a maquina do desenvolvedor + # contra loops do analyzer; nao mexem no comportamento funcional. + cpus: 2 + mem_limit: 1g + pids_limit: 128 + # Em dev exposto no host para debug local; em prod feche com `expose: ["8000"]`. ports: - - "${AI_SERVICE_PORT:-8000}:8000" + - "${AI_SERVICE_BIND:-127.0.0.1}:${AI_SERVICE_PORT:-8000}:8000" - # Permite acesso ao Ollama no Windows + # Permite acesso ao Ollama rodando no host (fora do Docker). O backend acessa + # pelo DNS interno do Compose; a porta no host fica local por padrao. extra_hosts: - "host.docker.internal:host-gateway" @@ -145,7 +194,10 @@ services: - repo_analysis_data:/app/workspace_analyzer environment: + - AI_SERVICE_TOKEN=${AI_SERVICE_TOKEN:?Configure AI_SERVICE_TOKEN in .env} - 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 @@ -161,6 +213,12 @@ services: - MAX_FILES=${MAX_FILES:-1000} - ANALYZER_QUICK_FILES=${ANALYZER_QUICK_FILES:-8} - ANALYZER_BALANCED_FILES=${ANALYZER_BALANCED_FILES:-80} + - ANALYZER_MAX_ACTIVE_RUNS=${ANALYZER_MAX_ACTIVE_RUNS:-2} + - ANALYZER_MAX_QUEUED_RUNS=${ANALYZER_MAX_QUEUED_RUNS:-6} + - ANALYZER_WORKSPACE_QUOTA_MB=${ANALYZER_WORKSPACE_QUOTA_MB:-2048} + - MAX_REPO_SIZE_MB=${MAX_REPO_SIZE_MB:-300} + - ANALYZER_CLONE_MEMORY_MB=${ANALYZER_CLONE_MEMORY_MB:-512} + - ANALYZER_CLONE_TIMEOUT_SECONDS=${ANALYZER_CLONE_TIMEOUT_SECONDS:-300} # PostgreSQL dentro do Docker - POSTGRES_HOST=postgres @@ -201,14 +259,14 @@ volumes: n8n_data: name: sinapse_n8n_data - documents_data: - name: sinapse_documents_data - repo_analysis_data: name: sinapse_repo_analysis_data + ollama_data: + name: sinapse_ollama_data + 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/DOCUMENT_UPLOAD.md b/docs/DOCUMENT_UPLOAD.md new file mode 100644 index 0000000..08d4a27 --- /dev/null +++ b/docs/DOCUMENT_UPLOAD.md @@ -0,0 +1,451 @@ +# Upload de Documentos - Guia para Desenvolvedores + +## Visão Geral + +O sistema Sinapse permite que usuários façam upload de documentos (PDF, DOCX, MD, TXT) associados a projetos. Após o upload, os documentos são processados pelo pipeline RAG para indexação e busca semântica. + +## Arquitetura + +``` +Frontend/Cliente + ↓ +Backend (Node.js/Express) + ↓ +Storage Compartilhado (./storage:/files) + ↓ +PostgreSQL (metadados) + ↓ +n8n Webhook + ↓ +Pipeline RAG (AI Service) +``` + +### Componentes + +| Componente | Tecnologia | Função | +|-----------|-----------|--------| +| **Frontend** | React 19 | Interface para upload de arquivos | +| **Backend** | Node.js 20 + Express | API REST, validação, armazenamento | +| **Storage** | Bind mount `./storage:/files` | Armazenamento compartilhado entre backend e n8n | +| **PostgreSQL** | PostgreSQL 16 + pgvector | Metadados dos documentos | +| **n8n** | n8n latest | Orquestrador de workflows de ingestão | +| **AI Service** | Python 3.11 + FastAPI | Processamento RAG e embeddings | + +## Estrutura de Armazenamento + +Os arquivos são salvos no storage compartilhado com a seguinte estrutura: + +``` +./storage/ +└── {projectId}/ + └── {documentId}.{extensao} +``` + +**Exemplo:** +``` +./storage/ +└── a0000000-0000-4000-8000-000000000001/ + └── c0000000-0000-4000-8000-000000000001.pdf +``` + +### Extensões Suportadas + +- `.pdf` - Documentos PDF +- `.docx` - Documentos Word +- `.md` - Markdown +- `.txt` - Texto puro + +## API de Upload + +### Endpoint + +``` +POST /api/v1/projects/{projectId}/documents +``` + +### Headers + +``` +Authorization: Bearer {token} +Content-Type: multipart/form-data +``` + +### Body (multipart/form-data) + +| Campo | Tipo | Obrigatório | Descrição | +|-------|------|-------------|-----------| +| `file` | File | Sim | Arquivo a ser enviado | + +### Resposta de Sucesso (201) + +```json +{ + "id": "c0000000-0000-4000-8000-000000000001", + "projeto_id": "a0000000-0000-4000-8000-000000000001", + "nome": "documento.pdf", + "extensao": ".pdf", + "mime": "application/pdf", + "tamanho_bytes": 123456, + "status_processamento": "pendente", + "armazenamento_pendente": false, + "autor_id": "b0000000-0000-4000-8000-000000000001", + "autor_nome": "Ana PO", + "created_at": "2026-10-02T15:30:00.000Z", + "updated_at": "2026-10-02T15:30:00.000Z" +} +``` + +### Respostas de Erro + +| Código | Descrição | +|-------|-----------| +| 400 | Arquivo inválido, vazio ou formato não suportado | +| 403 | Sem permissão de escrita (perfil dev) | +| 404 | Projeto não encontrado | +| 409 | Projeto arquivado (somente leitura) | +| 413 | Arquivo acima do limite configurado (default: 20MB) | + +## Fluxo de Processamento + +### 1. Upload do Arquivo + +O usuário envia o arquivo via frontend para o endpoint do backend. + +### 2. Validação + +O backend valida: +- Tamanho do arquivo (limite configurado via `DOCUMENT_MAX_SIZE_MB`) +- Extensão do arquivo (deve ser .pdf, .docx, .md ou .txt) +- Conteúdo do arquivo (correspondência entre extensão e MIME real) +- Nome do arquivo (sanitização para path traversal) + +### 3. Armazenamento + +O arquivo é salvo no storage compartilhado: +- Caminho: `{projectId}/{documentId}.{extensao}` +- Processo atômico com staging (`.{extensao}.uploading`) +- Finalização via rename para garantir consistência + +### 4. Registro no PostgreSQL + +Os metadados são registrados na tabela `documento`: +- `id`: UUID do documento +- `projeto_id`: UUID do projeto +- `nome`: Nome original do arquivo +- `extensao`: Extensão do arquivo +- `mime`: Tipo MIME detectado +- `tamanho_bytes`: Tamanho em bytes +- `caminho`: Caminho no storage +- `usuario_id`: UUID do usuário que fez upload +- `status_processamento`: `pendente` (inicial) + +### 5. Webhook n8n + +Após armazenamento bem-sucedido, o backend chama o webhook do n8n: + +**URL (do container backend):** `POST http://n8n:5678/webhook/sinapse-ingest` +**URL (local):** `POST http://localhost:5678/webhook/sinapse-ingest` + +**Importante:** sempre use `/webhook/` (produção), em qualquer ambiente, para a chamada automática do backend. +O endpoint `/webhook-test/` só responde a **uma única chamada manual** feita logo após clicar em +"Execute workflow" no editor do n8n — ele não fica escutando de verdade. Usá-lo como `N8N_WEBHOOK_URL` +funciona enquanto alguém testa com o editor aberto, mas falha com `404 - webhook not registered` assim +que o backend chama o webhook sem um humano armando o teste no editor antes. Para que `/webhook/` +responda, o workflow precisa estar **ativado** (veja "Ativação do Workflow" abaixo). + +**Configuração:** +- Defina `N8N_WEBHOOK_URL` no `.env` ou docker-compose.yml +- Default: `http://n8n:5678/webhook/sinapse-ingest` + +**Payload:** +```json +{ + "documentId": "c0000000-0000-4000-8000-000000000001", + "projectId": "a0000000-0000-4000-8000-000000000001", + "filename": "documento.pdf", + "storagePath": "a0000000-0000-4000-8000-000000000001/c0000000-0000-4000-8000-000000000001.pdf" +} +``` + +### 6. Processamento RAG + +O workflow n8n (`Sinapse - RAG Ingestão / Chat de teste`) encadeia: +1. **Normalizar entrada** → extrai `documentId`, `projectId`, `filename`, `storagePath` do payload do webhook. +2. **Ler arquivo de /files** → lê o binário do storage compartilhado. +3. **Extrair PDF / CSV / texto** (switch por extensão) + **Consolidar texto**. +4. **Chunking (ai-service)** → `POST http://ai-service:8000/chunk` com `{document_id, project_id, filename, text}`; + resposta: `{chunks: [{index, content, metadata}]}`. +5. **Embeddings bge-m3 (ai-service)** → `POST http://ai-service:8000/embed` com `{model, texts: [...]}`; + resposta: `{model, dimension, embeddings: [[1024 floats], ...]}`. +6. **Montar e validar chunks** → casa cada embedding ao chunk correspondente e valida 1024d. +7. **Persistir chunks (backend)** → `POST http://backend:3001/api/v1/projects/{projectId}/documents/{documentId}/chunks` + com `{chunks: [{chunk_index, content, metadata, embedding}]}`. Essa chamada é **server-to-server** e + autentica com `Authorization: Bearer $N8N_INGEST_TOKEN` (nunca sessão de usuário). O backend substitui + quaisquer chunks anteriores do documento (reindexação idempotente), persiste em `chunk` com `vector(1024)` + e marca `status_processamento = 'processado'`. +8. **Resposta HTTP** confirma o recebimento ao backend que chamou o webhook. + +Toda a persistência em `chunk` passa pelo backend Node — o ai-service só gera conteúdo +(chunking + embeddings), nunca grava direto, conforme AGENTS.md §12 ("Node.js como único +escritor persistente"). + +**Credencial `Header Auth` no n8n:** `Name = Authorization`, `Value = Bearer `. + +## Exemplos de Requisição + +### Testar Webhook n8n Diretamente + +Para testar o webhook do n8n sem fazer upload pelo backend: + +```bash +curl -X POST http://localhost:5678/webhook/sinapse-ingest \ + -H "Content-Type: application/json" \ + -d '{ + "documentId": "test-001", + "projectId": "760bf960-d302-4219-aba5-0e29f7eb03b5", + "filename": "documento.pdf", + "storagePath": "760bf960-d302-4219-aba5-0e29f7eb03b5/test.pdf" + }' +``` + +**Nota:** o workflow precisa estar ativado para que `/webhook/` responda (veja "Ativação do Workflow" +abaixo). Use `/webhook-test/` apenas para testar manualmente dentro do editor do n8n — nunca para +automação, pois ele só atende uma chamada por clique em "Execute workflow". + +### cURL + +```bash +# 1. Login para obter token +TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"danieldias@galaticos.com","password":"123456"}' \ + | jq -r '.token') + +# 2. Listar projetos para obter o projectId +curl -s http://localhost:3001/api/v1/projects \ + -H "Authorization: Bearer $TOKEN" + +# 3. Upload do documento (substitua {projectId} pelo ID real) +curl -X POST http://localhost:3001/api/v1/projects/{projectId}/documents \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@/caminho/do/documento.pdf" +``` + +### Postman + +1. Configure a nova requisição: + - **Method:** `POST` + - **URL:** `http://localhost:3001/api/v1/projects/{projectId}/documents` + - **Headers:** + - `Authorization`: `Bearer {token}` + - **Body:** + - Selecione `form-data` + - Chave: `file` + - Tipo: `File` + - Valor: selecione o arquivo + +### JavaScript/Fetch + +```javascript +const formData = new FormData(); +formData.append('file', fileInput.files[0]); + +const response = await fetch( + `http://localhost:3001/api/v1/projects/${projectId}/documents`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + }, + body: formData, + } +); + +const result = await response.json(); +``` + +## Configuração + +### Variáveis de Ambiente + +| Variável | Default | Descrição | +|----------|---------|-----------| +| `DOCUMENT_MAX_SIZE_MB` | 20 | Tamanho máximo do arquivo em MB | +| `DOCUMENT_STORAGE_DIR` | `/files` | Diretório de armazenamento no container | +| `N8N_WEBHOOK_URL` | `http://n8n:5678/webhook/sinapse-ingest` | URL do webhook n8n | + +### Docker Compose + +O `docker-compose.yml` configura o storage compartilhado: + +```yaml +services: + backend: + volumes: + - ./storage:/files + environment: + - DOCUMENT_STORAGE_DIR=/files + - N8N_WEBHOOK_URL=http://n8n:5678/webhook/sinapse-ingest + + n8n: + volumes: + - ./storage:/files +``` + +## Verificação e Debug + +### Verificar arquivo no storage + +```bash +# Listar arquivos do projeto +ls -la ./storage/{projectId}/ + +# Verificar arquivo específico +file ./storage/{projectId}/{documentId}.{extensao} +``` + +### Verificar arquivo no container n8n + +```bash +# Entrar no container n8n +docker exec -it sinapse-n8n sh + +# Listar arquivos +ls -la /files/{projectId}/ + +# Ler arquivo +cat /files/{projectId}/{documentId}.{extensao} +``` + +Nota: arquivos enviados **antes** do fix de permissão de `documents.storage.ts` (modo `0o600`, legível +só pelo dono) ainda aparecem sem acesso de leitura para o container n8n. Reenvie o documento ou rode +`chmod -R o+r ./storage` no host para liberar os arquivos já existentes. +``` + +### Verificar metadados no PostgreSQL + +```bash +# Entrar no container postgres +docker exec -it sinapse-postgres psql -U sinapse -d sinapse + +# Consultar documento +SELECT id, nome, extensao, caminho, status_processamento +FROM documento +WHERE projeto_id = '{projectId}'; +``` + +### Verificar chunks gerados + +```bash +# Consultar chunks do documento +SELECT id, texto, metadados_json +FROM chunk +WHERE entidade_tipo = 'documento' + AND entidade_id = '{documentId}'; +``` + +## Tratamento de Erros + +### Erros de Upload + +| Erro | Causa | Solução | +|------|-------|---------| +| 400 - "Arquivo inválido" | Extensão não suportada | Use PDF, DOCX, MD ou TXT | +| 400 - "O arquivo está vazio" | Arquivo sem conteúdo | Envie um arquivo com conteúdo | +| 413 - "Arquivo acima do limite" | Arquivo muito grande | Reduza o tamanho ou aumente `DOCUMENT_MAX_SIZE_MB` | +| 404 - "Projeto não encontrado" | ID do projeto inválido | Verifique o projectId | +| 409 - "Projeto arquivado" | Projeto em modo leitura | O projeto arquivado não aceita novos documentos | + +### Erros de Webhook n8n + +| Erro | Causa | Solução | +|------|-------|---------| +| 404 - "Webhook not registered" | Workflow não ativado | Ative o workflow na interface do n8n (nunca troque para `/webhook-test/`) | +| 403 / EACCES ao ler o arquivo | Arquivo gravado antes do fix de permissão (0o600) | Reenvie o documento ou rode `chmod -R o+r ./storage` | +| Timeout | n8n não respondeu | Verifique se o container n8n está rodando | +| Connection refused | Porta errada ou n8n parado | Verifique `docker compose ps` | + +### Erros de Processamento + +| Erro | Causa | Solução | +|------|-------|---------| +| 400 - "Arquivo inválido" | Extensão não suportada | Use PDF, DOCX, MD ou TXT | +| 400 - "O arquivo está vazio" | Arquivo sem conteúdo | Envie um arquivo com conteúdo | +| 413 - "Arquivo acima do limite" | Arquivo muito grande | Reduza o tamanho ou aumente `DOCUMENT_MAX_SIZE_MB` | +| 404 - "Projeto não encontrado" | ID do projeto inválido | Verifique o projectId | +| 409 - "Projeto arquivado" | Projeto em modo leitura | O projeto arquivado não aceita novos documentos | + +### Erros de Processamento + +Se o webhook n8n falhar: +- O documento permanece salvo no storage +- `status_processamento` permanece `pendente` +- O background worker pode reprocessar documentos pendentes +- Verifique logs do backend e n8n para detalhes + +## Segurança + +### Sanitização de Caminhos + +- Nomes de arquivos são sanitizados para remover caminhos relativos (`../`) +- Extensões são validadas contra lista permitida +- IDs gerados via UUID para evitar colisões +- Paths são construídos com template string seguro + +### Isolamento por Projeto + +- Cada projeto tem seu próprio diretório no storage +- Queries PostgreSQL sempre filtram por `projeto_id` +- n8n workflow processa no contexto do projeto específico + +## Manutenção + +### Limpeza de Arquivos Órfãos + +O background worker do backend: +- Reconcilia arquivos em staging (`.uploading`, `.removing`) +- Remove arquivos sem registro no PostgreSQL +- Marca operações falhadas para retry + +### Reindexação + +Para reprocessar documentos com status `pendente`: +```bash +# O background worker processa automaticamente +# Ou chame manualmente o endpoint de manutenção (se disponível) +``` + +## Workflow n8n + +O workflow atual está em `n8n/workflows/kbeyMs38qerFoS65-sinapse-document-ingestion-trigger.json`: + +1. **Webhook Ingestão** - Recebe payload do backend +2. **Ler arquivo de /files** - Lê arquivo do storage compartilhado +3. **Enviar para AI Service** - Envia conteúdo para processamento +4. **Resposta HTTP** - Confirma recebimento + +### Ativação do Workflow + +O backend sempre chama `/webhook/sinapse-ingest` (produção), em qualquer ambiente — essa é a única +forma de o n8n responder sem um humano com o editor aberto. Importar o JSON do workflow **não** ativa +automaticamente a instância (o campo `"active": true` do arquivo descreve a intenção, não o estado +real após importar); é preciso ativar explicitamente: + +1. Acesse `http://localhost:5678` +2. Abra o workflow "Sinapse - Document Ingestion Trigger" +3. Clique no toggle no canto superior direito até aparecer "Active" +4. Confirme com `curl -X POST http://localhost:5678/webhook/sinapse-ingest -d '{}'` — enquanto + inativo, a resposta é `404` com `"The requested webhook is not registered"` + +Ou, com `n8n-local-sync` configurado: + 1. Configure `N8N_API_KEY` no `.env` + 2. Execute: `n8n-local-sync validate` + +Use `/webhook-test/` **somente** para depurar o workflow manualmente dentro do editor (clicar em +"Execute workflow" arma um único recebimento); nunca como `N8N_WEBHOOK_URL`. + +## Referências + +- [AGENTS.md](../AGENTS.md) - Contexto do projeto +- [OpenAPI](api/openapi.yaml) - Contrato completo da API +- [Architecture README](Architecture/README.md) - Arquitetura do sistema diff --git a/docs/PLANO_FECHAMENTO_PR_UNICO_S2.md b/docs/PLANO_FECHAMENTO_PR_UNICO_S2.md new file mode 100644 index 0000000..b26d58a --- /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 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. + +## 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..77652f6 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. | @@ -26,10 +28,13 @@ Este índice separa os guias operacionais, contratos e especificações de produ - [OpenAPI do backend](api/openapi.yaml): endpoints, autenticação, payloads e respostas. - [Compatibilidade de rotas legadas](api/epics-compat.yaml): rotas mantidas para clientes antigos. +- [Upload de Documentos](DOCUMENT_UPLOAD.md): guia completo para desenvolvedores sobre upload, arquitetura, fluxo de processamento e exemplos de requisição. - [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/S2-01-DEMONSTRACAO.md b/docs/S2-01-DEMONSTRACAO.md new file mode 100644 index 0000000..34ee17e --- /dev/null +++ b/docs/S2-01-DEMONSTRACAO.md @@ -0,0 +1,19 @@ +# Demonstração integrada S2-01 + +Data: 07/10/2026, 23:17:44 (America/Sao_Paulo). + +Backend com código local, PostgreSQL 16/pgvector temporário, n8n e IA/Ollama reais. Migrações aplicadas, dados sintéticos, autenticação e chamadas HTTP reais. Worker acionado por tick explícito para controlar a demonstração; agendamento periódico não medido. + +- Upload HTTP 201 em 58 ms; estado pendente. +- Falha controlada no caminho backend → n8n → IA real: token inválido recusado; motivo seguro: O serviço de processamento recusou a solicitação. +- Dois pedidos de retry HTTP 202 preservaram fila, contadores e timestamps. +- Estado processando observado pela API durante execução real. +- Consulta de projetos HTTP 200 durante processamento. +- Estado processado; 1 trecho(s), embeddings reais de 1024 dimensões, origem preservada e documento encontrado na busca. +- Busca no segundo projeto não retornou dados do primeiro. +- PDF truncado falhou na extração da IA real: INVALID_DOCUMENT; motivo: Não foi possível extrair o conteúdo do documento. +- Falha permanente de extração permite nova tentativa manual HTTP 202. + +Asserções aprovadas. Banco e arquivos temporários removidos ao terminar; dados de desenvolvimento preservados. Sem credenciais no relatório. + +Reproduzir no backend com serviços locais ativos: node --import tsx scripts/demo-s201.mts. diff --git a/docs/S2-01-PIPELINE-DOCUMENTOS.md b/docs/S2-01-PIPELINE-DOCUMENTOS.md new file mode 100644 index 0000000..ae10018 --- /dev/null +++ b/docs/S2-01-PIPELINE-DOCUMENTOS.md @@ -0,0 +1,13 @@ +# S2-01 — Pipeline assíncrono de documentos + +Upload retorna com estado pendente. O worker consulta a fila persistida a cada 15 segundos, aguardando finalização do armazenamento e excluindo projetos arquivados. Estados: pendente, processando, processado e falha. + +Tentativas usam lease de três minutos e timeout HTTP de dois minutos. Falhas transitórias usam backoff exponencial e até cinco tentativas por ciclo. Leases expirados são recuperados após reinício; expiração da quinta tentativa informa ATTEMPTS_EXHAUSTED. Falhas permanentes aguardam nova tentativa manual. + +POST /api/v1/projects/{projectId}/documents/{documentId}/reprocess exige admin ou PO com acesso ao projeto e retorna 202. Pedidos repetidos enquanto pendente preservam a fila e os contadores. Lease ativo retorna 409. Após falha ou lease expirado, limpa diagnósticos e reinicia o ciclo. Documentos processados também podem ser reindexados, preservando o contrato existente. + +GET /api/v1/projects/{projectId}/documents expõe estado, erro_processamento (motivo seguro), erro_processamento_codigo, tentativas_processamento e nova_tentativa_pendente. Respostas de leases substituídos e documentos removidos não restauram dados. O Node valida escopo e vetores e substitui os trechos atomicamente com IDs determinísticos e projeto de origem. + +DOCUMENT_INGEST_WEBHOOK_URL seleciona o workflow n8n versionado; sem essa configuração, o backend chama a IA diretamente. Ative o workflow e encaminhe X-Service-Token. O workflow não salva payloads de execução. Logs registram somente IDs, tentativa, código, retry e status HTTP, sem conteúdo, credenciais ou exceções internas. + +Validação: npm test e npm run typecheck no backend. documents.retry.test.ts verifica idempotência, conflito, lease expirado e motivos seguros. documents.ingestion.db.test.ts verifica falha, retry, leases, vetores e remoção com PostgreSQL migrado e ARCHIVE_TEST_DATABASE_URL apontando para um banco de testes permitido. Sem essa variável, os testes de banco são ignorados. Revisão do PR por outra pessoa permanece uma etapa humana. 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..a597ac9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1544,7 +1544,7 @@ paths: nome: {type: string, minLength: 2} email: {type: string, format: email} password: {type: string, minLength: 6} - role: {type: string, enum: [po, dev, admin], default: po, description: "admin só é aceito com sessão de administrador"} + role: {type: string, enum: [po, dev, admin], default: dev, description: "po e admin só são aceitos com sessão de administrador"} responses: '201': description: Conta criada e sessão iniciada @@ -1555,6 +1555,31 @@ paths: '409': description: E-mail já cadastrado + /api/v1/chat/projects: + get: + summary: Lista projetos permitidos no chat (admin/PO ou alocação ativa do desenvolvedor) + operationId: listChatProjects + security: [{cookieAuth: []}] + responses: + '200': {description: Lista items com id e nome dos projetos} + '401': {$ref: '#/components/responses/Unauthorized'} + /api/v1/projects/{projectId}/documents/{documentId}/reprocess: + parameters: + - {name: projectId, in: path, required: true, schema: {type: string, format: uuid}} + - {name: documentId, in: path, required: true, schema: {type: string, format: uuid}} + post: + summary: Reenfileira a ingestão de um documento (admin ou PO) + description: Pedidos repetidos enquanto pendente preservam a fila e os contadores. Tentativas ativas retornam 409. A indexação substitui atomicamente os trechos do documento. + operationId: reprocessDocument + security: [{cookieAuth: []}] + responses: + '202': {description: Documento pendente para reprocessamento} + '400': {$ref: '#/components/responses/ValidationError'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {description: Sem permissão de escrita} + '404': {description: Projeto ou documento inexistente} + '409': {description: Projeto arquivado ou documento ainda em processamento} + /api/v1/chat/conversations: get: summary: Lista as conversas do usuário autenticado @@ -1609,6 +1634,8 @@ paths: post: summary: Envia uma pergunta ao assistente e registra a troca description: > + Projeto obrigatório em novas conversas; em conversas existentes o projeto é imutável. + Somente admin/PO ou desenvolvedor com alocação ativa pode consultar o projeto. Sem evidência no acervo, responde "Informação não encontrada no acervo do projeto." sem inventar fontes. `origem` informa se a resposta veio do assistente, de busca textual (assistente indisponível) ou sem resultado. @@ -1672,6 +1699,12 @@ paths: summary: Inicia uma análise de repositório público do GitHub operationId: startRepoAnalysis security: [{cookieAuth: []}] + parameters: + - name: Idempotency-Key + in: header + required: false + schema: {type: string, format: uuid} + description: Reutilize a mesma chave ao repetir o envio da mesma solicitação. requestBody: required: true content: @@ -1688,7 +1721,7 @@ paths: description: Limite priorizado de arquivos para análise; quick e balanced reduzem chamadas ao modelo local. responses: '201': - description: Análise iniciada + description: Solicitação persistida na fila; despacho assíncrono e idempotente '400': $ref: '#/components/responses/ValidationError' '401': @@ -1741,7 +1774,7 @@ paths: - {name: projectId, in: path, required: true, schema: {type: string, format: uuid}} - {name: id, in: path, required: true, schema: {type: string, format: uuid}} responses: - '200': {description: Análise retomada} + '200': {description: Retomada registrada na fila} '401': {$ref: '#/components/responses/Unauthorized'} '404': {description: Projeto ou análise não encontrados} '409': {description: Estado da análise não permite retomada} @@ -1762,19 +1795,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: @@ -1800,18 +1848,125 @@ paths: '403': description: Somente administrador + /api/v1/admin/ingestion: + get: + summary: Observabilidade do pipeline de ingestão de documentos (S2-01/S2-02) + description: > + Snapshot em tempo real do estado do worker: contagens por status + (pendente/processando/processado/falha), documentos ativos, falhas + recentes e últimas atualizações. Substitui o editor do n8n como + ferramenta visual para admin acompanhar a fila. Somente administrador. + operationId: adminIngestionSnapshot + security: [{cookieAuth: []}] + parameters: + - name: limit + in: query + required: false + schema: {type: integer, minimum: 1, maximum: 100, default: 25} + responses: + '200': + description: Snapshot do pipeline + content: + application/json: + schema: + type: object + required: [counts, recent, active, failed, generated_at] + properties: + counts: + type: object + required: [pendente, processando, processado, falha] + properties: + pendente: {type: integer, minimum: 0} + processando: {type: integer, minimum: 0} + processado: {type: integer, minimum: 0} + falha: {type: integer, minimum: 0} + recent: {type: array, items: {type: object}} + active: {type: array, items: {type: object}} + failed: {type: array, items: {type: object}} + generated_at: {type: string, format: date-time} + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Somente administrador + + /api/v1/admin/demo-seed: + post: + summary: Carrega exemplos demonstrativos em um projeto explícito (somente administrador) + description: Carga idempotente e auditada. Não gera vetores nem reindexa documentos. Projetos arquivados são rejeitados. + operationId: adminLoadDemo + security: [{cookieAuth: []}] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [projeto_id] + additionalProperties: false + properties: + projeto_id: {type: string, format: uuid} + responses: + '200': + description: Carga concluída; inseridos é zero quando já aplicada + content: + application/json: + schema: + type: object + properties: + projeto_id: {type: string, format: uuid} + inseridos: {type: integer, minimum: 0, maximum: 2} + status: {type: string, enum: [sucesso]} + message: {type: string} + '400': + description: Projeto de destino ausente ou inválido + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Somente administrador + '404': + description: Projeto não encontrado + '409': + description: Projeto arquivado + /api/v1/admin/ingest-seed: post: - summary: Carrega dados de demonstração no acervo (somente administrador) + deprecated: true + summary: Carrega exemplos demonstrativos em um projeto explícito (somente administrador) + description: Carga idempotente e auditada. Não gera vetores nem reindexa documentos. Projetos arquivados são rejeitados. operationId: adminIngestSeed security: [{cookieAuth: []}] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [projeto_id] + additionalProperties: false + properties: + projeto_id: {type: string, format: uuid} responses: '200': - description: Dados carregados + description: Carga concluída; inseridos é zero quando já aplicada + content: + application/json: + schema: + type: object + properties: + projeto_id: {type: string, format: uuid} + inseridos: {type: integer, minimum: 0, maximum: 2} + status: {type: string, enum: [sucesso]} + message: {type: string} + '400': + description: Projeto de destino ausente ou inválido '401': $ref: '#/components/responses/Unauthorized' '403': description: Somente administrador + '404': + description: Projeto não encontrado + '409': + description: Projeto arquivado /api/v1: get: @@ -1918,7 +2073,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 +2157,81 @@ 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} + + /api/v1/projects/{projectId}/documents/{documentId}/chunks: + post: + summary: Persiste chunks indexados do documento (ferramenta de debug via n8n, S2-02) + description: > + Endpoint server-to-server opcional usado apenas quando o workflow do n8n + é ativado como ferramenta de debug manual. O fluxo canônico (worker) + chama o ai-service direto e persiste pelos métodos internos do + repository. Esta rota aceita `Authorization: Bearer ` + separado da sessão de usuário. Cada embedding deve ter 1024 dimensões + (bge-m3). Substitui qualquer chunk anterior do mesmo documento. + operationId: persistDocumentChunks + security: + - bearerAuth: [] + parameters: + - name: projectId + in: path + required: true + schema: {type: string, format: uuid} + - name: documentId + in: path + required: true + schema: {type: string, format: uuid} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [chunks] + properties: + chunks: + type: array + items: + type: object + required: [chunk_index, content, embedding] + properties: + chunk_index: {type: integer, minimum: 0} + content: {type: string} + metadata: {type: object, additionalProperties: true} + embedding: + type: array + minItems: 1024 + maxItems: 1024 + items: {type: number} + responses: + '200': {description: Chunks persistidos} + '400': {$ref: '#/components/responses/ValidationError'} + '401': {description: Token de ingestão ausente ou inválido} + '404': {description: Projeto ou documento não encontrados} + '409': {description: Projeto arquivado é somente leitura (ARCHIVE_CONFLICT)} + /webhook/sinapse-document-removed: servers: - url: http://localhost:5678 @@ -3217,8 +3447,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 +3595,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/e2e/README.md b/e2e/README.md index 371394c..3e21511 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -45,3 +45,9 @@ Variáveis opcionais: `E2E_API_URL`, `E2E_APP_URL`, `E2E_CHROME_PATH` e `E2E_TIM O workflow [E2E (navegador)](../.github/workflows/e2e.yml) roda automaticamente em pull requests para `main` e em pushes para `main` quando mudanças afetam backend, frontend, banco, E2E, Compose ou o próprio workflow. Também pode ser iniciado manualmente pelo `workflow_dispatch`. Cada execução de CI provisiona PostgreSQL descartável, aplica migrations, inicia frontend/backend e executa os cenários no Chrome. O job publica logs dos serviços quando falha. + +## Preparação de identidades e isolamento + +Defina E2E_DATABASE_URL para PostgreSQL descartável cujo banco se chama sinapse_e2e_test; npm test recusa outro nome. O setup cria somente o administrador de teste nesse banco. POs são cadastrados pela API autenticada, desenvolvedores pela API pública, e as alocações usadas pelos cenários são explícitas. Nenhuma exceção é adicionada às rotas de produção. + +O processo e2e/server.mts sobe a aplicação real sem workers de processamento (NODE_ENV=test); a suíte verifica persistência da solicitação e cancelamento antes do despacho. Workers, ingestão e limites do Analyzer têm testes próprios. Isso mantém os E2E independentes de downloads GitHub e inferência Ollama. Execute no diretório backend: NODE_ENV=test node --import tsx ../e2e/server.mts. O frontend deve encaminhar /api para essa API. No Windows, E2E_CHROME_PATH também aceita o executável do Edge/Chromium. diff --git a/e2e/database.mjs b/e2e/database.mjs new file mode 100644 index 0000000..72ff49e --- /dev/null +++ b/e2e/database.mjs @@ -0,0 +1,19 @@ +import { createRequire } from 'node:module'; +const require = createRequire(new URL('../backend/package.json', import.meta.url)); +const { Pool } = require('pg'); + +export async function testDatabase(work) { + const connectionString = process.env.E2E_DATABASE_URL; + if (!connectionString || !/^sinapse_e2e_test$/.test(new URL(connectionString).pathname.slice(1))) { + throw new Error('E2E_DATABASE_URL deve apontar para um banco dedicado chamado sinapse_e2e_test.'); + } + const db = new Pool({ connectionString, max: 1 }); + try { return await work(db); } finally { await db.end(); } +} + +export async function allocateDeveloper(account, project) { + await testDatabase(async db => { + const developer = await db.query('INSERT INTO desenvolvedor(usuario_id) VALUES($1) RETURNING id', [account.user.id]); + await db.query('INSERT INTO alocacao(desenvolvedor_id,projeto_id) VALUES($1,$2)', [developer.rows[0].id, project.id]); + }); +} diff --git a/e2e/run.mjs b/e2e/run.mjs index 651796b..2011ffb 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -1,6 +1,9 @@ import { spawnSync } from "node:child_process"; import { readdirSync } from "node:fs"; import { join } from "node:path"; +import { setup } from './setup.mjs'; + +await setup(); const files = readdirSync("tests").filter((file) => file.endsWith(".e2e.mjs")).sort().map((file) => join("tests", file)); if (files.length === 0) throw new Error("Nenhum teste E2E encontrado."); diff --git a/e2e/server.mts b/e2e/server.mts new file mode 100644 index 0000000..9de3d93 --- /dev/null +++ b/e2e/server.mts @@ -0,0 +1,5 @@ +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const { default: app } = require('../backend/src/app.ts'); +const port = Number(process.env.PORT ?? 3001); +app.listen(port, '127.0.0.1', () => console.log(`E2E API: http://127.0.0.1:${port}`)); diff --git a/e2e/setup.mjs b/e2e/setup.mjs new file mode 100644 index 0000000..2bde97f --- /dev/null +++ b/e2e/setup.mjs @@ -0,0 +1,12 @@ +import { randomBytes, scryptSync } from 'node:crypto'; +import { testDatabase } from './database.mjs'; + +// Test-only bootstrap: no production endpoint or change to registration authorization. +export async function setup() { + const salt = randomBytes(16).toString('hex'); + const hash = ['scrypt', 'v1', salt, scryptSync('E2E-admin-only-123', salt, 64).toString('hex')].join('$'); + await testDatabase(async db => { + await db.query(`INSERT INTO usuario(nome,email,senha_hash,role) VALUES('Admin E2E','admin@e2e.test',$1,'admin') + ON CONFLICT(email) DO UPDATE SET senha_hash=$1, role='admin', tentativas_login=0, bloqueado_ate=NULL`, [hash]); + }); +} diff --git a/e2e/support.mjs b/e2e/support.mjs index 0a84966..5b0c72e 100644 --- a/e2e/support.mjs +++ b/e2e/support.mjs @@ -7,14 +7,17 @@ export const PASSWORD = "Senha-forte-123"; export async function api(path, { token, method = "GET", body, headers = {} } = {}) { const raw = body instanceof Uint8Array; + const form = body instanceof FormData; const response = await fetch(`${API}${path}`, { method, headers: { - ...(body && !raw ? { "Content-Type": "application/json" } : {}), + // FormData: o fetch gera o Content-Type com boundary sozinho; forçar + // aqui quebraria o parsing multipart do multer no backend. + ...(body && !raw && !form ? { "Content-Type": "application/json" } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...headers, }, - body: raw ? body : body ? JSON.stringify(body) : undefined, + body: raw || form ? body : body ? JSON.stringify(body) : undefined, }); const text = await response.text(); let json; @@ -23,8 +26,14 @@ export async function api(path, { token, method = "GET", body, headers = {} } = } export async function createUser(role) { + let token; + if (role !== 'dev') { + const admin = await api('/auth/login', { method: 'POST', body: { email: 'admin@e2e.test', password: 'E2E-admin-only-123' } }); + if (admin.status !== 200) throw new Error('Execute o setup do banco E2E isolado antes dos testes.'); + token = admin.json.token; + } const email = `${role}-${randomUUID().slice(0, 8)}@e2e.test`; - const registered = await api("/auth/register", { method: "POST", body: { nome: `E2E ${role}`, email, password: PASSWORD, role } }); + const registered = await api("/auth/register", { token, method: "POST", body: { nome: `E2E ${role}`, email, password: PASSWORD, role } }); if (registered.status !== 201 && registered.status !== 200) throw new Error(`register ${role}: ${registered.status} ${JSON.stringify(registered.json)}`); const login = await api("/auth/login", { method: "POST", body: { email, password: PASSWORD } }); if (login.status !== 200) throw new Error(`login ${role}: ${login.status}`); @@ -48,11 +57,15 @@ export async function archiveProject(account, project) { } export function uploadRaw(account, projectId, name, content) { + // Backend usa multer com upload.single("file"): exige multipart/form-data, + // campo "file" — mesmo contrato que o frontend usa (ver api_documents.ts). + const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content; + const form = new FormData(); + form.append("file", new Blob([bytes]), name); return api(`/projects/${projectId}/documents`, { method: "POST", token: account.token, - body: typeof content === "string" ? new TextEncoder().encode(content) : content, - headers: { "Content-Type": "application/octet-stream", "X-File-Name": encodeURIComponent(name) }, + body: form, }); } @@ -86,9 +99,9 @@ export async function assertNoHorizontalOverflow(page, label) { } export async function createHierarchy(account, project, labels = {}) { - const epic = await api("/epics", { method: "POST", token: account.token, body: { projeto_id: project.id, titulo: labels.epic ?? "Épico de autenticação", descricao: "Objetivo de acesso seguro à plataforma" } }); + const epic = await api("/epics", { method: "POST", token: account.token, body: { projeto_id: project.id, titulo: labels.epic ?? "Épico de autenticação", descricao: "Objetivo de acesso seguro à plataforma", objetivo: "Garantir acesso seguro", escopo_macro: "Autenticação e sessão", resultado_esperado: "Acessos autenticados" } }); if (epic.status !== 201) throw new Error(`épico: ${epic.status} ${JSON.stringify(epic.json)}`); - const feature = await api("/features", { method: "POST", token: account.token, body: { epico_id: epic.json.id, titulo: labels.feature ?? "Sessão do usuário", descricao: "Controle de sessão e expiração" } }); + const feature = await api("/features", { method: "POST", token: account.token, body: { epico_id: epic.json.id, titulo: labels.feature ?? "Sessão do usuário", descricao: "Controle de sessão e expiração", objetivo: "Gerenciar sessão", criterios_sucesso: "Sessão válida", comportamento_esperado: "Expirar acesso inválido" } }); if (feature.status !== 201) throw new Error(`feature: ${feature.status} ${JSON.stringify(feature.json)}`); const pbi = await api("/pbis", { method: "POST", diff --git a/e2e/tests/assistants.e2e.mjs b/e2e/tests/assistants.e2e.mjs index 13cf730..dce6df5 100644 --- a/e2e/tests/assistants.e2e.mjs +++ b/e2e/tests/assistants.e2e.mjs @@ -22,8 +22,12 @@ test("RepoAnalyzer: valida a URL, explica a indisponibilidade do motor e respeit await page.getByLabel(/URL do repositório/).fill("https://github.com/acme/api"); await page.getByRole("button", { name: "Iniciar análise" }).click(); - await expect(page.getByText(/Falha ao iniciar análise no motor de IA/)).toBeVisible(); - assert.equal(await page.getByLabel(/URL do repositório/).inputValue(), "https://github.com/acme/api"); + await expect(page.getByRole('button', { name: 'Cancelar análise', exact: true })).toBeVisible(); + const pending = await api(`/projects/${project.id}/repo-analyses`, { token: po.token }); + assert.equal(pending.json.length, 1); + assert.equal(pending.json[0].dispatch_pending, true); + const cancelled = await api(`/projects/${project.id}/repo-analyses/${pending.json[0].id}/cancel`, { method:'POST', token:po.token }); + assert.ok(['cancelada','cancelando'].includes(cancelled.json.status)); } finally { await context.close(); } diff --git a/e2e/tests/auth.e2e.mjs b/e2e/tests/auth.e2e.mjs new file mode 100644 index 0000000..bd1c544 --- /dev/null +++ b/e2e/tests/auth.e2e.mjs @@ -0,0 +1,39 @@ +import test, { after } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { APP, getBrowser, closeBrowser, createUser } from '../support.mjs'; +import { expect } from './expect.mjs'; +after(closeBrowser); + +test('login pela tela restaura sessão e cadastro público cria somente dev', async () => { + const account = await createUser('dev'); + const context = await (await getBrowser()).newContext(); + try { + const page = await context.newPage(); + await page.goto(`${APP}/login`); + await page.getByLabel('E-mail', {exact:true}).fill(account.email); + await page.getByLabel('Senha', {exact:true}).fill(account.password); + const login = page.waitForResponse(r=>r.url().endsWith('/auth/login') && r.request().method()==='POST'); + await page.getByRole('button',{name:'Entrar',exact:true}).click(); + assert.equal((await login).status(),200); + await page.goto(`${APP}/projects`); + await expect(page.getByRole('heading',{name:'Projetos',exact:true})).toBeVisible(); + await page.reload(); + await expect(page.getByRole('heading',{name:'Projetos',exact:true})).toBeVisible(); + } finally { await context.close(); } + const anonymous = await (await getBrowser()).newContext(); + try { + const page = await anonymous.newPage(); + await page.goto(`${APP}/login`); + await page.getByRole('button',{name:'Cadastrar',exact:true}).click(); + await page.getByLabel('Nome Completo', {exact:true}).fill('Pessoa E2E'); + await page.getByLabel('E-mail Corporativo', {exact:true}).fill(`ui-${randomUUID()}@e2e.test`); + await page.getByLabel('Senha (mínimo 6 caracteres)', {exact:true}).fill('Senha-forte-123'); + await page.getByLabel(/Confirmar senha/i).fill('Senha-forte-123'); + const registered = page.waitForResponse(r=>r.url().endsWith('/auth/register') && r.request().method()==='POST'); + await page.getByRole('button',{name:'Cadastrar e Entrar',exact:true}).click(); + const response = await registered; + assert.equal(response.status(),201); + assert.equal((await response.json()).user.role,'dev'); + } finally { await anonymous.close(); } +}); diff --git a/e2e/tests/documents.e2e.mjs b/e2e/tests/documents.e2e.mjs index dd56471..8fb526c 100644 --- a/e2e/tests/documents.e2e.mjs +++ b/e2e/tests/documents.e2e.mjs @@ -1,3 +1,4 @@ +import { allocateDeveloper } from '../database.mjs'; import test, { after } from "node:test"; import assert from "node:assert/strict"; import { expect } from "./expect.mjs"; @@ -58,6 +59,7 @@ test("perfil dev consulta mas não escreve, e a API recusa a escrita", async () const po = await createUser("po"); const dev = await createUser("dev"); const project = await createProject(po, "Docs DEV"); + await allocateDeveloper(dev, project); const uploaded = await uploadRaw(po, project.id, "leitura.pdf", pdfContent()); assert.equal(uploaded.status, 201); diff --git a/e2e/tests/flows.e2e.mjs b/e2e/tests/flows.e2e.mjs index fdbaa30..84acbf0 100644 --- a/e2e/tests/flows.e2e.mjs +++ b/e2e/tests/flows.e2e.mjs @@ -1,3 +1,4 @@ +import { allocateDeveloper } from '../database.mjs'; import test, { after } from "node:test"; import assert from "node:assert/strict"; import { expect } from "./expect.mjs"; @@ -129,6 +130,7 @@ test("Decisões S1-18: registrar no PBI, ver herança do projeto e restrições const po = await createUser("po"); const dev = await createUser("dev"); const project = await createProject(po, "Decisões"); + await allocateDeveloper(dev, project); const { epic, feature, pbi } = await createHierarchy(po, project); const inherited = await api(`/projects/${project.id}/decisions`, { method: "POST", token: po.token, body: { titulo: "Stack única em TypeScript", contexto: "Equipe pequena", decisao: "Usar TypeScript em todo o código", justificativa: "Reduz troca de contexto", alternativas: "Python no backend" } }); assert.equal(inherited.status, 201); @@ -189,7 +191,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); diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..10dd600 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,19 @@ +# Organização do frontend + +- `src/api`: clientes HTTP e contratos de transporte. +- `src/models`: modelos, navegação e funções puras do domínio. +- `src/viewmodels`: hooks de estado compartilhado e autenticação. +- `src/views`: telas e componentes organizados por funcionalidade. +- `src/views/common`: componentes compartilhados; primitivas visuais em `ui`. +- `src/styles`: tokens, componentes e layout globais. +- `src/assets/styles`: estilos específicos de funcionalidades, incluindo documentos. + +Os imports apontam diretamente para a implementação. Os antigos módulos de reexportação em `auth`, `backlog`, `components` e `projects` foram removidos; seus testes agora ficam junto às funcionalidades. + +Projetos têm componentes separados para listagem, formulário e detalhe. PBIs seguem a mesma divisão. A árvore do backlog separa filtros, renderização dos nós e carregamento/interações da tela. As funções de filtro não dependem de React. + +O dashboard distingue serviços verificados de serviços sem diagnóstico. O estado do banco vem do healthcheck do backend; falhas de acesso pelo navegador não são tratadas como prova de indisponibilidade dos serviços não verificados. + +`src/views/admin/IngestionObservabilityView.tsx` (rota `/admin/ingestion`, só admin) acompanha em tempo real o pipeline de ingestão de documentos — substitui o editor do n8n como ferramenta visual. Ver [../IMPLEMENTACAO_PIPELINE_INGESTAO.md](../IMPLEMENTACAO_PIPELINE_INGESTAO.md). + +Execute `npm test` e `npm run build` nesta pasta. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 52d3db5..ad1d215 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -6,7 +6,7 @@ const { mockUser } = vi.hoisted(() => ({ mockUser: { id: "test-user", name: "Test User", role: "po" as "admin" | "po" | "dev" }, })); -vi.mock("./auth/Auth", () => ({ +vi.mock("./viewmodels/useAuthViewModel", () => ({ useAuth: () => ({ session: { status: "authenticated", user: mockUser }, logout: vi.fn(), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 001eee3..9e64f4e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ +import { Button } from "./views/common/ui"; import React, { useState, useEffect } from "react"; import { isProjectPath, navigate, routes } from "./models/navigation"; -import { useAuth } from "./auth/Auth"; +import { useAuth } from "./viewmodels/useAuthViewModel"; import { ProjectsView } from "./views/projects/ProjectsView"; import { LandingPageView } from "./views/landing/LandingPageView"; import { DeveloperDashboardView } from "./views/dashboard/DeveloperDashboardView"; @@ -8,8 +9,8 @@ import { DocumentsView } from "./views/documents/DocumentsView"; import { KnowledgeView } from "./views/knowledge/KnowledgeView"; import { ChatView } from "./views/chat/ChatView"; import { AdminView } from "./views/admin/AdminView"; +import { IngestionObservabilityView } from "./views/admin/IngestionObservabilityView"; import { RequirementsView } from "./views/backlog/RequirementsView"; -import "./assets/styles/garakis-prototype.css"; export const App: React.FC = () => { const { session, logout } = useAuth(); @@ -29,8 +30,7 @@ export const App: React.FC = () => { }, []); return ( -
- {/* Top Header Bar (Protótipo Garakis) */} +
{ event.preventDefault(); navigate("/projects"); }}> S•NAPSE @@ -63,28 +63,28 @@ export const App: React.FC = () => { -
+
{isAdmin ? ( ) : ( - {session.user?.nome || session.user?.name || "Cauan Gabriel"} · {session.user?.role?.toUpperCase() || "PO"} + {session.user?.nome || session.user?.name || "Usuário"} · {session.user?.role?.toUpperCase() || "PO"} )} - +
{/* Main Content Area */} -
+
{activeTab === "projects" && } {activeTab === "requirements" && } {activeTab === "documents" && } @@ -94,7 +94,14 @@ export const App: React.FC = () => {

Acesso restrito

Esta área está disponível somente para administradores.

- + +
+ ))} + {activeTab === "ingestion" && (isAdmin ? : ( +
+

Acesso restrito

+

O pipeline de ingestão é visível somente para administradores.

+
))} {activeTab === "architecture" && } @@ -102,7 +109,7 @@ export const App: React.FC = () => {
{/* Footer */} -
+
Sinapse · PRO4TECH · Fatec São José dos Campos · Grupo Galáticos · 2º Semestre/2026
diff --git a/frontend/src/api/api_auth.ts b/frontend/src/api/api_auth.ts index 9fe350c..311f82d 100644 --- a/frontend/src/api/api_auth.ts +++ b/frontend/src/api/api_auth.ts @@ -7,8 +7,11 @@ export class ApiError extends Error { export async function apiRequest(path: string, init: RequestInit = {}) { const token = localStorage.getItem("app_auth_token"); + // Com FormData o browser precisa gerar o próprio Content-Type com boundary; + // injetar um default aqui quebraria o parsing de multipart no backend. + const isFormData = typeof FormData !== "undefined" && init.body instanceof FormData; const headers: Record = { - "Content-Type": "application/json", + ...(isFormData ? {} : { "Content-Type": "application/json" }), ...(init.headers as Record), }; diff --git a/frontend/src/backlog/api.completude.test.ts b/frontend/src/api/api_backlog.completude.test.ts similarity index 82% rename from frontend/src/backlog/api.completude.test.ts rename to frontend/src/api/api_backlog.completude.test.ts index 11c2276..8d868ff 100644 --- a/frontend/src/backlog/api.completude.test.ts +++ b/frontend/src/api/api_backlog.completude.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "vitest"; -import { hasCompletudeIndicator } from "../api/api_backlog"; +import { hasCompletudeIndicator } from "./api_backlog"; it("considera 0% como indicador válido e oculta somente quando a pontuação é nula", () => { expect(hasCompletudeIndicator(0)).toBe(true); diff --git a/frontend/src/api/api_chat.ts b/frontend/src/api/api_chat.ts index 50ce895..906f573 100644 --- a/frontend/src/api/api_chat.ts +++ b/frontend/src/api/api_chat.ts @@ -24,6 +24,7 @@ export interface ChatMessage { fontes: ChatSource[]; created_at: string | null; origem?: ChatOrigin; + failed?: boolean; } export interface ChatAnswer { @@ -74,6 +75,7 @@ function parseMessage(value: unknown): ChatMessage { id: value.id, remetente: value.remetente as ChatMessage["remetente"], conteudo: value.conteudo, + failed: value.processing_status === "failed", fontes: parseSources(value.fontes_json ?? value.fontes), created_at: typeof value.created_at === "string" ? value.created_at : null, }; @@ -120,3 +122,9 @@ export function describeChatError(error: unknown): string { } return "Não foi possível falar com o assistente agora. Verifique a conexão e tente novamente."; } + +export async function listChatProjects(signal?: AbortSignal): Promise> { + const data = await (await apiRequest("/chat/projects", {signal})).json(); + if (!Array.isArray(data.items)) throw new Error("Projetos inválidos"); + return data.items; +} diff --git a/frontend/src/api/api_documents.test.ts b/frontend/src/api/api_documents.test.ts index 8c50f54..853f787 100644 --- a/frontend/src/api/api_documents.test.ts +++ b/frontend/src/api/api_documents.test.ts @@ -36,7 +36,7 @@ it("recusa respostas fora do contrato", async () => { await expect(listDocuments("p-1")).rejects.toThrow("Limites de envio inválidos"); }); -it("envia o arquivo como corpo binário com o nome codificado e sem depender do MIME do cliente", async () => { +it("envia o arquivo como multipart/form-data no campo 'file' esperado pelo multer do backend", async () => { const request = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify(document), { status: 201 })); vi.stubGlobal("fetch", request); const file = new File(["conteudo"], "Especificação final.pdf", { type: "text/plain" }); @@ -44,10 +44,14 @@ it("envia o arquivo como corpo binário com o nome codificado e sem depender do const [url, init] = request.mock.calls[0]; expect(url).toBe("/api/v1/projects/p-1/documents"); expect(init?.method).toBe("POST"); - expect(init?.body).toBe(file); - const headers = init?.headers as Record; - expect(headers["Content-Type"]).toBe("application/octet-stream"); - expect(decodeURIComponent(headers["X-File-Name"])).toBe("Especificação final.pdf"); + expect(init?.body).toBeInstanceOf(FormData); + const form = init?.body as FormData; + const sent = form.get("file"); + expect(sent).toBeInstanceOf(File); + expect((sent as File).name).toBe("Especificação final.pdf"); + const headers = (init?.headers ?? {}) as Record; + // Content-Type do multipart vem do próprio FormData com boundary; nunca forçar aqui. + expect(headers["Content-Type"]).toBeUndefined(); }); it("remove com DELETE no documento do projeto", async () => { diff --git a/frontend/src/api/api_documents.ts b/frontend/src/api/api_documents.ts index f16a253..388004e 100644 --- a/frontend/src/api/api_documents.ts +++ b/frontend/src/api/api_documents.ts @@ -12,7 +12,12 @@ export interface ProjectDocument { mime: string | null; tamanho_bytes: number | null; status_processamento: DocumentStatus; + processamento_erro?: string | null; armazenamento_pendente: boolean; + nova_tentativa_pendente?: boolean; + tentativas_processamento?: number; + proxima_tentativa_em?: string | null; + erro_processamento_codigo?: string | null; autor_id: string | null; autor_nome: string | null; created_at: string; @@ -54,7 +59,12 @@ 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, + nova_tentativa_pendente: value.nova_tentativa_pendente === true, + tentativas_processamento: typeof value.tentativas_processamento === "number" ? value.tentativas_processamento : 0, + proxima_tentativa_em: typeof value.proxima_tentativa_em === "string" ? value.proxima_tentativa_em : null, + erro_processamento_codigo: typeof value.erro_processamento_codigo === "string" ? value.erro_processamento_codigo : null, autor_id: typeof value.autor_id === "string" ? value.autor_id : null, autor_nome: typeof value.autor_nome === "string" ? value.autor_nome : null, created_at: value.created_at, @@ -89,13 +99,13 @@ export async function listDocuments( } export async function uploadDocument(projectId: string, file: File, signal?: AbortSignal): Promise { + // Backend usa multer com upload.single("file"), que exige multipart/form-data. + // Não passamos Content-Type manualmente — o fetch gera o boundary correto. + const body = new FormData(); + body.append("file", file, file.name); const response = await apiRequest(`/projects/${encodeURIComponent(projectId)}/documents`, { method: "POST", - headers: { - "Content-Type": "application/octet-stream", - "X-File-Name": encodeURIComponent(file.name), - }, - body: file, + body, signal: signal ?? AbortSignal.timeout(UPLOAD_TIMEOUT_MS), }); return parseDocument(await response.json()); @@ -108,6 +118,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`; @@ -152,3 +169,7 @@ export function describeRemovalError(error: unknown): string { } return "Não foi possível remover o documento. Ele continua disponível; tente novamente."; } + +export async function reprocessDocument(projectId: string, documentId: string, signal?: AbortSignal): Promise { + await apiRequest(`/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}/reprocess`, { method: "POST", signal }); +} diff --git a/frontend/src/api/api_ingestion.ts b/frontend/src/api/api_ingestion.ts new file mode 100644 index 0000000..a00c22b --- /dev/null +++ b/frontend/src/api/api_ingestion.ts @@ -0,0 +1,83 @@ +import { apiRequest } from "./api_auth"; + +export type IngestionStatus = "pendente" | "processando" | "processado" | "falha"; + +export interface IngestionCounts { + pendente: number; + processando: number; + processado: number; + falha: number; +} + +export interface IngestionDocument { + id: string; + projeto_id: string; + projeto_nome: string; + nome: string; + extensao: string | null; + tamanho_bytes: number | null; + status_processamento: IngestionStatus; + processamento_tentativas: number; + processamento_erro: string | null; + processamento_proxima_tentativa: string | null; + created_at: string; + updated_at: string; +} + +export interface IngestionSnapshot { + counts: IngestionCounts; + recent: IngestionDocument[]; + active: IngestionDocument[]; + failed: IngestionDocument[]; + generated_at: string; +} + +const STATUSES: readonly IngestionStatus[] = ["pendente", "processando", "processado", "falha"]; + +function parseDocument(value: unknown): IngestionDocument | null { + if (!value || typeof value !== "object") return null; + const row = value as Record; + if (typeof row.id !== "string" || typeof row.nome !== "string" || typeof row.projeto_id !== "string") return null; + if (!STATUSES.includes(row.status_processamento as IngestionStatus)) return null; + return { + id: row.id, + projeto_id: row.projeto_id, + projeto_nome: typeof row.projeto_nome === "string" ? row.projeto_nome : "—", + nome: row.nome, + extensao: typeof row.extensao === "string" ? row.extensao : null, + tamanho_bytes: typeof row.tamanho_bytes === "number" ? row.tamanho_bytes : null, + status_processamento: row.status_processamento as IngestionStatus, + processamento_tentativas: typeof row.processamento_tentativas === "number" ? row.processamento_tentativas : 0, + processamento_erro: typeof row.processamento_erro === "string" ? row.processamento_erro : null, + processamento_proxima_tentativa: typeof row.processamento_proxima_tentativa === "string" ? row.processamento_proxima_tentativa : null, + created_at: typeof row.created_at === "string" ? row.created_at : "", + updated_at: typeof row.updated_at === "string" ? row.updated_at : "", + }; +} + +export async function fetchIngestionSnapshot(signal?: AbortSignal): Promise { + const response = await apiRequest("/admin/ingestion?limit=25", { signal }); + const data = await response.json() as Record; + const counts = (data.counts ?? {}) as Record; + const parse = (list: unknown): IngestionDocument[] => Array.isArray(list) + ? list.map(parseDocument).filter((item): item is IngestionDocument => item !== null) + : []; + return { + counts: { + pendente: Number(counts.pendente ?? 0), + processando: Number(counts.processando ?? 0), + processado: Number(counts.processado ?? 0), + falha: Number(counts.falha ?? 0), + }, + recent: parse(data.recent), + active: parse(data.active), + failed: parse(data.failed), + generated_at: typeof data.generated_at === "string" ? data.generated_at : new Date().toISOString(), + }; +} + +export async function reprocessIngestionDocument(projectId: string, documentId: string): Promise { + await apiRequest(`/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}/retry`, { + method: "POST", + }); +} diff --git a/frontend/src/projects/repo-analyzer.api.test.ts b/frontend/src/api/api_repo_analyzer.test.ts similarity index 94% rename from frontend/src/projects/repo-analyzer.api.test.ts rename to frontend/src/api/api_repo_analyzer.test.ts index 71f8f7c..03ffd6d 100644 --- a/frontend/src/projects/repo-analyzer.api.test.ts +++ b/frontend/src/api/api_repo_analyzer.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from 'vitest'; -import { controlRepoAnalysis, startRepoAnalysis } from './repo-analyzer.api'; +import { controlRepoAnalysis, startRepoAnalysis } from './api_repo_analyzer'; afterEach(() => vi.unstubAllGlobals()); diff --git a/frontend/src/projects/repo-analyzer.api.ts b/frontend/src/api/api_repo_analyzer.ts similarity index 93% rename from frontend/src/projects/repo-analyzer.api.ts rename to frontend/src/api/api_repo_analyzer.ts index 22ef2a4..3271f13 100644 --- a/frontend/src/projects/repo-analyzer.api.ts +++ b/frontend/src/api/api_repo_analyzer.ts @@ -1,4 +1,4 @@ -import { apiRequest } from "../api/api_auth"; +import { apiRequest } from "./api_auth"; export type RepoAnalysisStatus = "iniciado" | "em_execucao" | "pausando" | "pausada" | "cancelando" | "cancelada" | "concluido" | "falha"; export type RepoAnalysisProfile = "quick" | "balanced" | "complete"; @@ -24,9 +24,10 @@ export interface RepoAnalysis { autor_email?: string | null; } -export async function startRepoAnalysis(projectId: string, repositoryUrl: string, profile: RepoAnalysisProfile = "quick", signal?: AbortSignal): Promise { +export async function startRepoAnalysis(projectId: string, repositoryUrl: string, profile: RepoAnalysisProfile = "quick", signal?: AbortSignal, requestKey?: string): Promise { const response = await apiRequest(`/projects/${encodeURIComponent(projectId)}/repo-analyses`, { method: "POST", + headers: requestKey ? { "Idempotency-Key": requestKey } : undefined, body: JSON.stringify({ repositorio_url: repositoryUrl, perfil: profile }), signal, }); diff --git a/frontend/src/assets/styles/backlog-search.css b/frontend/src/assets/styles/backlog-search.css index a02f883..08698f0 100644 --- a/frontend/src/assets/styles/backlog-search.css +++ b/frontend/src/assets/styles/backlog-search.css @@ -1,6 +1,6 @@ .backlog-search-field { flex: 2 1 260px; min-width: 0; } .backlog-search-field input { width: 100%; padding: 10px 12px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--bg-secondary); color: var(--text-primary); font: inherit; } -.backlog-search-back { padding: 0; background: none; border: 0; color: var(--orange); text-decoration: underline; font: inherit; } +.backlog-search-back { padding: 0; background: none; border: 0; color: var(--brand-primary); text-decoration: underline; font: inherit; } .backlog-search-results { display: grid; gap: 12px; } .backlog-search-summary { color: var(--text-secondary); font-size: .9rem; margin: 0; } .backlog-search-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; } @@ -8,13 +8,13 @@ .backlog-search-item-head { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } .backlog-search-code { font: 12px var(--font-mono); color: var(--text-muted); } .backlog-search-title { padding: 0; background: none; border: 0; color: #fff; font-size: 1rem; font-weight: 600; text-align: left; overflow-wrap: anywhere; } -.backlog-search-title:hover { color: var(--orange); text-decoration: underline; } +.backlog-search-title:hover { color: var(--brand-primary); text-decoration: underline; } .backlog-search-snippet { margin: 0; color: var(--text-secondary); font-size: .9rem; line-height: 1.6; overflow-wrap: anywhere; } -.backlog-search-snippet mark { background: color-mix(in srgb, var(--orange) 30%, transparent); color: #fff; border-radius: 3px; padding: 0 2px; } +.backlog-search-snippet mark { background: color-mix(in srgb, var(--brand-primary) 30%, transparent); color: #fff; border-radius: 3px; padding: 0 2px; } .backlog-search-path ol { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: .8rem; color: var(--text-muted); } .backlog-search-path li:not(:last-child)::after { content: "›"; margin-left: 6px; color: var(--text-muted); } .backlog-search-path button { padding: 0; background: none; border: 0; color: var(--text-secondary); font: inherit; text-decoration: underline; text-decoration-color: transparent; } -.backlog-search-path button:hover { color: var(--orange); text-decoration-color: currentColor; } +.backlog-search-path button:hover { color: var(--brand-primary); text-decoration-color: currentColor; } .backlog-search-path [aria-current="page"] { color: var(--text-primary); } .backlog-search-tech { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; } .backlog-search-tech li { padding: 2px 10px; border: 1px solid var(--border-subtle); border-radius: 999px; font-size: .75rem; color: var(--text-secondary); } diff --git a/frontend/src/assets/styles/chat.css b/frontend/src/assets/styles/chat.css index 5d2d11f..a926404 100644 --- a/frontend/src/assets/styles/chat.css +++ b/frontend/src/assets/styles/chat.css @@ -1,41 +1,41 @@ .chatx-layout { display: grid; grid-template-columns: minmax(250px, 300px) minmax(0, 1fr); gap: 20px; margin-top: 16px; align-items: stretch; } .chat-sidebar { display: flex; flex-direction: column; gap: 16px; padding: 16px; min-width: 0; } -.chat-conversations { display: grid; gap: 8px; align-content: start; overflow-y: auto; max-height: 460px; border-top: 1px solid var(--line); padding-top: 12px; } +.chat-conversations { display: grid; gap: 8px; align-content: start; overflow-y: auto; max-height: 460px; border-top: 1px solid var(--border-subtle); padding-top: 12px; } .chat-conversations ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } -.chat-conversation { width: 100%; display: grid; gap: 2px; padding: 10px 12px; text-align: left; color: var(--text); background: transparent; border: 1px solid transparent; border-radius: 8px; transition: background .15s ease, border-color .15s ease; } -.chat-conversation:hover { background: var(--card); } -.chat-conversation[aria-current="true"] { background: color-mix(in srgb, var(--orange) 14%, transparent); border-color: var(--orange); } +.chat-conversation { width: 100%; display: grid; gap: 2px; padding: 10px 12px; text-align: left; color: var(--text-primary); background: transparent; border: 1px solid transparent; border-radius: 8px; transition: background .15s ease, border-color .15s ease; } +.chat-conversation:hover { background: var(--bg-tertiary); } +.chat-conversation[aria-current="true"] { background: color-mix(in srgb, var(--brand-primary) 14%, transparent); border-color: var(--brand-primary); } .chat-conversation-title { font-size: 13.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.chat-conversation-meta { font-size: 12px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chat-conversation-meta { font-size: 12px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .chat-thread { display: flex; flex-direction: column; gap: 14px; padding: 18px; min-width: 0; min-height: 560px; } -.chat-thread-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; border-bottom: 1px solid var(--line); padding-bottom: 12px; } +.chat-thread-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; border-bottom: 1px solid var(--border-subtle); padding-bottom: 12px; } .chat-thread-header h2 { margin: 0; font-size: 16px; overflow-wrap: anywhere; } .chat-messages { flex: 1; display: flex; flex-direction: column; gap: 14px; min-height: 320px; max-height: 520px; overflow-y: auto; padding: 4px 6px 4px 0; } -.chat-messages:focus-visible { outline: 2px solid var(--orange); outline-offset: 2px; border-radius: 8px; } +.chat-messages:focus-visible { outline: 2px solid var(--brand-primary); outline-offset: 2px; border-radius: 8px; } -.chatx-welcome { margin: auto; max-width: 560px; text-align: center; display: grid; gap: 10px; color: var(--muted); } -.chatx-welcome h3 { margin: 0; color: var(--text); font-size: 18px; } +.chatx-welcome { margin: auto; max-width: 560px; text-align: center; display: grid; gap: 10px; color: var(--text-secondary); } +.chatx-welcome h3 { margin: 0; color: var(--text-primary); font-size: 18px; } .chat-suggestions { display: grid; gap: 8px; margin-top: 6px; } -.chat-suggestions button { padding: 10px 14px; text-align: left; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 10px; transition: border-color .15s ease, background .15s ease; } -.chat-suggestions button:hover { border-color: var(--orange); background: var(--card); } +.chat-suggestions button { padding: 10px 14px; text-align: left; color: var(--text-primary); background: var(--surface-base); border: 1px solid var(--border-subtle); border-radius: 10px; transition: border-color .15s ease, background .15s ease; } +.chat-suggestions button:hover { border-color: var(--brand-primary); background: var(--bg-tertiary); } -.chat-message { display: grid; gap: 8px; max-width: min(760px, 88%); padding: 12px 16px; border-radius: 12px; border: 1px solid var(--line); background: color-mix(in srgb, var(--card) 70%, transparent); } +.chat-message { display: grid; gap: 8px; max-width: min(760px, 88%); padding: 12px 16px; border-radius: 12px; border: 1px solid var(--border-subtle); background: color-mix(in srgb, var(--bg-tertiary) 70%, transparent); } .chat-message header { display: flex; align-items: center; gap: 10px; font-size: 12px; } -.chat-message header b { color: var(--orange); font-weight: 700; } -.chat-message header time { color: var(--dim); } -.chat-message--user { align-self: flex-end; background: color-mix(in srgb, var(--orange) 16%, var(--bg)); border-color: color-mix(in srgb, var(--orange) 45%, transparent); } +.chat-message header b { color: var(--brand-primary); font-weight: 700; } +.chat-message header time { color: var(--text-muted); } +.chat-message--user { align-self: flex-end; background: color-mix(in srgb, var(--brand-primary) 16%, var(--surface-base)); border-color: color-mix(in srgb, var(--brand-primary) 45%, transparent); } .chat-message--user header b { color: #fff; } .chat-message--assistant { align-self: flex-start; } -.chat-message.is-failed { border-style: dashed; border-color: var(--red); } -.chat-message-failed { margin: 0; color: var(--red); font-size: 12px; font-weight: 600; } +.chat-message.is-failed { border-style: dashed; border-color: var(--status-danger); } +.chat-message-failed { margin: 0; color: var(--status-danger); font-size: 12px; font-weight: 600; } .chat-message-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; } .chat-message-body { font-size: 14px; } -.chat-sources { font-size: 12.5px; border-top: 1px solid var(--line); padding-top: 8px; } -.chat-sources summary { cursor: pointer; color: var(--muted); } +.chat-sources { font-size: 12.5px; border-top: 1px solid var(--border-subtle); padding-top: 8px; } +.chat-sources summary { cursor: pointer; color: var(--text-secondary); } .chat-sources ul { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; } .chat-message-actions { display: flex; justify-content: flex-end; } -.chat-typing { align-self: flex-start; padding: 8px 14px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); font-size: 13px; } +.chat-typing { align-self: flex-start; padding: 8px 14px; border-radius: 999px; border: 1px solid var(--border-subtle); color: var(--text-secondary); font-size: 13px; } .chat-composer { display: grid; gap: 8px; } .chat-composer textarea { resize: vertical; min-height: 76px; max-height: 220px; } diff --git a/frontend/src/assets/styles/decisions.css b/frontend/src/assets/styles/decisions.css index b4587ec..996c1fd 100644 --- a/frontend/src/assets/styles/decisions.css +++ b/frontend/src/assets/styles/decisions.css @@ -2,19 +2,19 @@ .decisions-head { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 12px; } .decisions-head h3 { margin: 0 0 4px; font-size: 1.1rem; } .decisions-head p { max-width: 62ch; font-size: .88rem; } -.decisions-group { margin: 0 0 10px; font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); } +.decisions-group { margin: 0 0 10px; font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--text-secondary); } .decision-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; } -.decision-card { padding: 14px 16px; border: 1px solid var(--line); border-left: 4px solid var(--orange); border-radius: var(--r); background: var(--bg); display: grid; gap: 8px; } +.decision-card { padding: 14px 16px; border: 1px solid var(--border-subtle); border-left: 4px solid var(--brand-primary); border-radius: var(--radius-md); background: var(--surface-base); display: grid; gap: 8px; } .decision-card header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; } .decision-card h4 { margin: 0; font-size: 1rem; overflow-wrap: anywhere; } -.decision-card--inherited { border-left-style: dashed; background: color-mix(in srgb, var(--card) 60%, transparent); } +.decision-card--inherited { border-left-style: dashed; background: color-mix(in srgb, var(--bg-tertiary) 60%, transparent); } .decision-card--inherited[data-level="projeto"] { border-left-color: var(--step-2); } .decision-card--inherited[data-level="epico"] { border-left-color: var(--step-3); } .decision-card--inherited[data-level="feature"] { border-left-color: var(--step-4); } -.decision-meta { margin: 0; font-size: .8rem; color: var(--dim); } +.decision-meta { margin: 0; font-size: .8rem; color: var(--text-muted); } .decision-card dl { margin: 0; display: grid; gap: 4px; } -.decision-card dt { font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin-top: 6px; } +.decision-card dt { font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--text-secondary); margin-top: 6px; } .decision-card dd { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; } -.decision-form { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--line); border-radius: var(--r); background: var(--bg); } +.decision-form { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-base); } .decision-form h4 { margin: 0; } -.decision-error { margin: 2px 0 0; color: var(--red); font-size: 12px; } +.decision-error { margin: 2px 0 0; color: var(--status-danger); font-size: 12px; } diff --git a/frontend/src/projects/documents.css b/frontend/src/assets/styles/documents.css similarity index 100% rename from frontend/src/projects/documents.css rename to frontend/src/assets/styles/documents.css 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/assets/styles/ingestion.css b/frontend/src/assets/styles/ingestion.css new file mode 100644 index 0000000..867c502 --- /dev/null +++ b/frontend/src/assets/styles/ingestion.css @@ -0,0 +1,197 @@ +/* IngestionObservabilityView: visual de pipeline estilo n8n editor, nativo. */ + +.ingestion-view { + display: grid; + gap: var(--space-6); +} + +.ingestion-head { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: var(--space-4); + flex-wrap: wrap; +} + +.ingestion-head-actions { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} + +.ingestion-counters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-4); +} + +.ingestion-counter { + padding: var(--space-5); + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: var(--bg-card); + display: grid; + gap: var(--space-2); +} + +.ingestion-counter-value { + font-size: 2.4rem; + font-weight: 700; + color: var(--text-primary); + line-height: 1; +} + +.ingestion-counter-label { + font-size: 0.85rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); +} + +.ingestion-counter--pendente { border-color: color-mix(in srgb, var(--status-warning) 55%, transparent); } +.ingestion-counter--processando { border-color: color-mix(in srgb, var(--brand-primary) 55%, transparent); } +.ingestion-counter--processado { border-color: color-mix(in srgb, var(--status-success) 55%, transparent); } +.ingestion-counter--falha { border-color: color-mix(in srgb, var(--status-danger-strong) 55%, transparent); } + +.ingestion-list { + display: grid; + gap: var(--space-4); +} + +.ingestion-row { + padding: var(--space-5); + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: var(--bg-card); + display: grid; + gap: var(--space-4); +} + +.ingestion-row--processando { border-color: color-mix(in srgb, var(--brand-primary) 40%, transparent); } +.ingestion-row--falha { border-color: color-mix(in srgb, var(--status-danger-strong) 45%, transparent); } +.ingestion-row--processado { border-color: color-mix(in srgb, var(--status-success) 45%, transparent); } + +.ingestion-row-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-3); + flex-wrap: wrap; +} + +.ingestion-row-head h4 { + margin: 0; + font-size: 1.05rem; +} + +.ingestion-row-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--space-3); + margin: 0; +} + +.ingestion-row-meta > div { + padding: var(--space-2) 0; + border-top: 1px solid var(--border-subtle); +} + +.ingestion-row-meta dt { + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 2px; +} + +.ingestion-row-meta dd { + margin: 0; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.ingestion-row-actions { + display: flex; + justify-content: flex-end; +} + +/* Pipeline bar (horizontal flow visualization) */ + +.pipeline-bar { + list-style: none; + padding: 0; + margin: 0; + display: flex; + align-items: center; + gap: 0; + position: relative; +} + +.pipeline-step { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + position: relative; + padding: var(--space-2) 0; +} + +.pipeline-step::before { + content: ""; + position: absolute; + top: calc(var(--space-2) + 7px); + right: 50%; + width: 100%; + height: 2px; + background: var(--border-subtle); + z-index: 0; +} + +.pipeline-step:first-child::before { display: none; } + +.pipeline-step--reached::before { background: var(--status-success); } +.pipeline-bar--failed .pipeline-step::before { background: color-mix(in srgb, var(--status-danger-strong) 60%, transparent); } + +.pipeline-step-dot { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--bg-tertiary); + border: 2px solid var(--border-strong); + z-index: 1; +} + +.pipeline-step--reached .pipeline-step-dot { + background: var(--status-success); + border-color: var(--status-success); +} + +.pipeline-step--current .pipeline-step-dot { + background: var(--brand-primary); + border-color: var(--brand-primary); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--brand-primary) 25%, transparent); + animation: pipeline-pulse 1.6s ease-in-out infinite; +} + +.pipeline-step--failure .pipeline-step-dot { + background: var(--status-danger); + border-color: var(--status-danger); +} + +.pipeline-step-label { + font-size: 0.78rem; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-secondary); +} + +.pipeline-step--reached .pipeline-step-label, +.pipeline-step--current .pipeline-step-label, +.pipeline-step--failure .pipeline-step-label { color: var(--text-primary); } + +@keyframes pipeline-pulse { + 0%, 100% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--brand-primary) 25%, transparent); } + 50% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--brand-primary) 10%, transparent); } +} diff --git a/frontend/src/assets/styles/repo-analyzer.css b/frontend/src/assets/styles/repo-analyzer.css index b9b376e..be27ae7 100644 --- a/frontend/src/assets/styles/repo-analyzer.css +++ b/frontend/src/assets/styles/repo-analyzer.css @@ -7,62 +7,62 @@ .repo-analyzer-state { padding: 32px 24px; display: grid; justify-items: center; gap: 12px; text-align: center; } .repo-analyzer-layout { display: grid; grid-template-columns: minmax(250px, 330px) minmax(0, 1fr); gap: 20px; align-items: start; } .analysis-history, .analysis-details { padding: 18px; display: grid; gap: 16px; align-content: start; min-width: 0; } -.analysis-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); padding-bottom: 10px; } +.analysis-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border-subtle); padding-bottom: 10px; } .analysis-history-header h3 { margin: 0; font-size: 15px; } .analysis-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; max-height: 560px; overflow-y: auto; } -.analysis-item { width: 100%; display: grid; gap: 8px; padding: 12px; text-align: left; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: var(--r); transition: border-color .15s ease, background .15s ease; } -.analysis-item:hover { border-color: var(--strong); background: var(--card); } -.analysis-item[aria-current="true"] { border-color: var(--orange); background: var(--card); } +.analysis-item { width: 100%; display: grid; gap: 8px; padding: 12px; text-align: left; color: var(--text-primary); background: var(--surface-base); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); transition: border-color .15s ease, background .15s ease; } +.analysis-item:hover { border-color: var(--border-strong); background: var(--bg-tertiary); } +.analysis-item[aria-current="true"] { border-color: var(--brand-primary); background: var(--bg-tertiary); } .analysis-item-url { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .analysis-item-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; } -.analysis-item-meta time { font-size: 12px; color: var(--dim); } +.analysis-item-meta time { font-size: 12px; color: var(--text-muted); } .analysis-details-header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 12px; } .analysis-details-header h3 { margin: 0 0 4px; font-size: 18px; overflow-wrap: anywhere; } -.analysis-details-header a { color: var(--text); text-decoration: none; border-bottom: 1px dashed var(--strong); } -.analysis-details-header a:hover { color: var(--orange); border-color: var(--orange); } +.analysis-details-header a { color: var(--text-primary); text-decoration: none; border-bottom: 1px dashed var(--border-strong); } +.analysis-details-header a:hover { color: var(--brand-primary); border-color: var(--brand-primary); } .analysis-stepper { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; counter-reset: step; } -.analysis-stepper li { display: grid; justify-items: center; gap: 6px; text-align: center; position: relative; color: var(--dim); font-size: 12px; } -.analysis-stepper li:not(:last-child)::after { content: ""; position: absolute; top: 14px; left: calc(50% + 18px); right: calc(-50% + 18px); height: 2px; background: var(--line); } -.analysis-stepper li[data-state="done"]:not(:last-child)::after { background: var(--orange); } -.analysis-step-marker { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; border: 2px solid var(--strong); background: var(--bg); font-size: 12px; font-weight: 700; position: relative; z-index: 1; } -.analysis-stepper li[data-state="done"] { color: var(--muted); } -.analysis-stepper li[data-state="done"] .analysis-step-marker { background: var(--orange); border-color: var(--orange); color: #fff; } -.analysis-stepper li[data-state="current"] { color: var(--text); font-weight: 600; } -.analysis-stepper li[data-state="current"] .analysis-step-marker { border-color: var(--orange); color: var(--orange); box-shadow: 0 0 0 4px color-mix(in srgb, var(--orange) 22%, transparent); } -.analysis-stepper li[data-state="failed"] { color: var(--red); font-weight: 600; } -.analysis-stepper li[data-state="failed"] .analysis-step-marker { border-color: var(--red); background: color-mix(in srgb, var(--red) 16%, transparent); color: var(--red); } +.analysis-stepper li { display: grid; justify-items: center; gap: 6px; text-align: center; position: relative; color: var(--text-muted); font-size: 12px; } +.analysis-stepper li:not(:last-child)::after { content: ""; position: absolute; top: 14px; left: calc(50% + 18px); right: calc(-50% + 18px); height: 2px; background: var(--border-subtle); } +.analysis-stepper li[data-state="done"]:not(:last-child)::after { background: var(--brand-primary); } +.analysis-step-marker { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; border: 2px solid var(--border-strong); background: var(--surface-base); font-size: 12px; font-weight: 700; position: relative; z-index: 1; } +.analysis-stepper li[data-state="done"] { color: var(--text-secondary); } +.analysis-stepper li[data-state="done"] .analysis-step-marker { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; } +.analysis-stepper li[data-state="current"] { color: var(--text-primary); font-weight: 600; } +.analysis-stepper li[data-state="current"] .analysis-step-marker { border-color: var(--brand-primary); color: var(--brand-primary); box-shadow: 0 0 0 4px color-mix(in srgb, var(--brand-primary) 22%, transparent); } +.analysis-stepper li[data-state="failed"] { color: var(--status-danger); font-weight: 600; } +.analysis-stepper li[data-state="failed"] .analysis-step-marker { border-color: var(--status-danger); background: color-mix(in srgb, var(--status-danger) 16%, transparent); color: var(--status-danger); } -.analysis-progress { display: grid; gap: 8px; padding: 14px; background: var(--bg); border: 1px solid var(--line); border-radius: var(--r); } +.analysis-progress { display: grid; gap: 8px; padding: 14px; background: var(--surface-base); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); } .analysis-controls { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; } .analysis-progress-line { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; font-weight: 600; } .analysis-languages { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; } -.analysis-languages li { padding: 3px 10px; border: 1px solid var(--line); border-radius: 999px; font-size: 12px; color: var(--muted); } -.analysis-languages b { color: var(--text); font-weight: 600; } +.analysis-languages li { padding: 3px 10px; border: 1px solid var(--border-subtle); border-radius: 999px; font-size: 12px; color: var(--text-secondary); } +.analysis-languages b { color: var(--text-primary); font-weight: 600; } .analysis-report { display: grid; gap: 12px; } -.analysis-report-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; border-top: 1px solid var(--line); padding-top: 16px; } +.analysis-report-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; border-top: 1px solid var(--border-subtle); padding-top: 16px; } .analysis-report-toolbar h4 { margin: 0; font-size: 15px; } .analysis-report-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } -.analysis-toggle { display: inline-flex; border: 1px solid var(--line); border-radius: 999px; overflow: hidden; } -.analysis-toggle button { padding: 5px 14px; background: transparent; border: 0; color: var(--muted); font-size: 13px; } -.analysis-toggle button[aria-pressed="true"] { background: var(--orange-bg); color: #fff; } -.analysis-report-body, .analysis-report-raw { max-height: 520px; overflow: auto; padding: 18px; background: var(--bg); border: 1px solid var(--line); border-radius: var(--r); } +.analysis-toggle { display: inline-flex; border: 1px solid var(--border-subtle); border-radius: 999px; overflow: hidden; } +.analysis-toggle button { padding: 5px 14px; background: transparent; border: 0; color: var(--text-secondary); font-size: 13px; } +.analysis-toggle button[aria-pressed="true"] { background: var(--brand-surface); color: #fff; } +.analysis-report-body, .analysis-report-raw { max-height: 520px; overflow: auto; padding: 18px; background: var(--surface-base); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); } .analysis-report-raw { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.6 var(--font-mono); } -.markdown { color: var(--text); font-size: 14px; line-height: 1.65; overflow-wrap: anywhere; } +.markdown { color: var(--text-primary); font-size: 14px; line-height: 1.65; overflow-wrap: anywhere; } .markdown > * + * { margin-top: 12px; } .markdown h2, .markdown h3, .markdown h4, .markdown h5 { margin: 18px 0 6px; color: #fff; } -.markdown h2 { font-size: 18px; border-bottom: 1px solid var(--line); padding-bottom: 6px; } +.markdown h2 { font-size: 18px; border-bottom: 1px solid var(--border-subtle); padding-bottom: 6px; } .markdown h3 { font-size: 16px; } .markdown h4, .markdown h5 { font-size: 14px; } .markdown ul, .markdown ol { padding-left: 22px; display: grid; gap: 4px; } -.markdown code { padding: 1px 6px; border-radius: 6px; background: var(--card); font: 12.5px var(--font-mono); } -.markdown pre { margin: 0; padding: 14px; overflow: auto; border: 1px solid var(--line); border-radius: var(--r); background: var(--card); } +.markdown code { padding: 1px 6px; border-radius: 6px; background: var(--bg-tertiary); font: 12.5px var(--font-mono); } +.markdown pre { margin: 0; padding: 14px; overflow: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--bg-tertiary); } .markdown pre code { padding: 0; background: transparent; } -.markdown blockquote { padding: 4px 14px; border-left: 3px solid var(--orange); color: var(--muted); } -.markdown a { color: var(--orange); } -.markdown hr { border: 0; border-top: 1px solid var(--line); } +.markdown blockquote { padding: 4px 14px; border-left: 3px solid var(--brand-primary); color: var(--text-secondary); } +.markdown a { color: var(--brand-primary); } +.markdown hr { border: 0; border-top: 1px solid var(--border-subtle); } @media (max-width: 900px) { .repo-analyzer-layout { grid-template-columns: 1fr; } diff --git a/frontend/src/auth/Auth.tsx b/frontend/src/auth/Auth.tsx deleted file mode 100644 index 5046a73..0000000 --- a/frontend/src/auth/Auth.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export { AuthProvider, useAuth, AuthContext } from "../viewmodels/useAuthViewModel"; -export type { Session, AuthContextType } from "../viewmodels/useAuthViewModel"; -export { AuthGate, AuthScreen, LoginOnCard, RegisterOnCard } from "../views/auth/AuthView"; diff --git a/frontend/src/auth/navigation.ts b/frontend/src/auth/navigation.ts deleted file mode 100644 index 35eed08..0000000 --- a/frontend/src/auth/navigation.ts +++ /dev/null @@ -1 +0,0 @@ -export { routes, isProjectPath, safeDestination, navigate, loginDestination } from "../models/navigation"; diff --git a/frontend/src/backlog/Backlog.tsx b/frontend/src/backlog/Backlog.tsx deleted file mode 100644 index 83fc1da..0000000 --- a/frontend/src/backlog/Backlog.tsx +++ /dev/null @@ -1 +0,0 @@ -export { BacklogScreen } from "../views/backlog/BacklogView"; diff --git a/frontend/src/backlog/Criteria.tsx b/frontend/src/backlog/Criteria.tsx deleted file mode 100644 index 22deb59..0000000 --- a/frontend/src/backlog/Criteria.tsx +++ /dev/null @@ -1 +0,0 @@ -export { CriteriaEditor } from "../views/backlog/CriteriaView"; \ No newline at end of file diff --git a/frontend/src/backlog/Epics.tsx b/frontend/src/backlog/Epics.tsx deleted file mode 100644 index 006e295..0000000 --- a/frontend/src/backlog/Epics.tsx +++ /dev/null @@ -1 +0,0 @@ -export { EpicList, EpicForm, EpicDetail } from "../views/backlog/EpicsView"; diff --git a/frontend/src/backlog/Features.tsx b/frontend/src/backlog/Features.tsx deleted file mode 100644 index 20559bf..0000000 --- a/frontend/src/backlog/Features.tsx +++ /dev/null @@ -1 +0,0 @@ -export { FeatureList, FeatureForm, FeatureDetail } from "../views/backlog/FeaturesView"; diff --git a/frontend/src/backlog/ItemArchive.tsx b/frontend/src/backlog/ItemArchive.tsx deleted file mode 100644 index 6ae52d3..0000000 --- a/frontend/src/backlog/ItemArchive.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ItemArchiveView as ItemArchive } from "../views/backlog/ItemArchiveView"; diff --git a/frontend/src/backlog/Pbis.tsx b/frontend/src/backlog/Pbis.tsx deleted file mode 100644 index 7d90d29..0000000 --- a/frontend/src/backlog/Pbis.tsx +++ /dev/null @@ -1 +0,0 @@ -export { PbiList, PbiForm, PbiDetail } from "../views/backlog/PbisView"; \ No newline at end of file diff --git a/frontend/src/backlog/QualityPanel.tsx b/frontend/src/backlog/QualityPanel.tsx deleted file mode 100644 index b95a868..0000000 --- a/frontend/src/backlog/QualityPanel.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { QualityPanelView as QualityPanel, scrollToField } from "../views/backlog/QualityPanelView"; -export type { QualityPanelProps } from "../views/backlog/QualityPanelView"; \ No newline at end of file diff --git a/frontend/src/backlog/ReadOnlyContext.ts b/frontend/src/backlog/ReadOnlyContext.ts deleted file mode 100644 index 1dca542..0000000 --- a/frontend/src/backlog/ReadOnlyContext.ts +++ /dev/null @@ -1 +0,0 @@ -export { ReadOnlyContext } from "../models/ReadOnlyContext"; diff --git a/frontend/src/backlog/fields.ts b/frontend/src/backlog/fields.ts deleted file mode 100644 index 8b8d21e..0000000 --- a/frontend/src/backlog/fields.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../models/fields"; diff --git a/frontend/src/backlog/navigation.ts b/frontend/src/backlog/navigation.ts deleted file mode 100644 index 41e8013..0000000 --- a/frontend/src/backlog/navigation.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { navigate, isBacklogPath, parseBacklogRoute } from "../models/navigation"; -export type { BacklogRoute } from "../models/navigation"; diff --git a/frontend/src/backlog/qualityEngine.ts b/frontend/src/backlog/qualityEngine.ts deleted file mode 100644 index 5c872df..0000000 --- a/frontend/src/backlog/qualityEngine.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../models/qualityEngine"; \ No newline at end of file diff --git a/frontend/src/backlog/useUnsavedChangesGuard.ts b/frontend/src/backlog/useUnsavedChangesGuard.ts deleted file mode 100644 index 11d95de..0000000 --- a/frontend/src/backlog/useUnsavedChangesGuard.ts +++ /dev/null @@ -1 +0,0 @@ -export { useUnsavedChangesGuard } from "../viewmodels/useUnsavedChangesGuard"; diff --git a/frontend/src/components/DeveloperDashboard.tsx b/frontend/src/components/DeveloperDashboard.tsx deleted file mode 100644 index f1caac4..0000000 --- a/frontend/src/components/DeveloperDashboard.tsx +++ /dev/null @@ -1 +0,0 @@ -export { DeveloperDashboardView as DeveloperDashboard } from "../views/dashboard/DeveloperDashboardView"; \ No newline at end of file diff --git a/frontend/src/components/LandingPageView.tsx b/frontend/src/components/LandingPageView.tsx deleted file mode 100644 index 72ced54..0000000 --- a/frontend/src/components/LandingPageView.tsx +++ /dev/null @@ -1 +0,0 @@ -export { LandingPageView } from "../views/landing/LandingPageView"; \ No newline at end of file diff --git a/frontend/src/components/SearchField.tsx b/frontend/src/components/SearchField.tsx deleted file mode 100644 index b53f8ec..0000000 --- a/frontend/src/components/SearchField.tsx +++ /dev/null @@ -1 +0,0 @@ -export { SearchField } from "../views/common/SearchField"; diff --git a/frontend/src/components/ui/index.tsx b/frontend/src/components/ui/index.tsx deleted file mode 100644 index 6bd16c3..0000000 --- a/frontend/src/components/ui/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export { - Alert, - Badge, - Button, - EmptyState, - Progress, - Field, - AISuggestion, - type BadgeTone, -} from "../../views/common/ui/index"; diff --git a/frontend/src/index.css b/frontend/src/index.css index 3a6cc70..42c03f1 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,5 +1,6 @@ @import "./styles/tokens.css"; @import "./styles/components.css"; +@import "./styles/layout.css"; .auth-shell { min-height: 100vh; @@ -302,97 +303,3 @@ button { border: none; outline: none; } - -/* Glassmorphic Container */ -.glass-panel { - background: var(--bg-card); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - box-shadow: var(--shadow-md); - transition: transform var(--transition-normal), border-color var(--transition-normal); -} - -.glass-panel:hover { - border-color: var(--border-active); -} - -/* Badges */ -.badge { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 12px; - border-radius: var(--radius-full); - border: 1px solid var(--border-strong); - background: transparent; - color: var(--text-secondary); - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.badge-success { - background: color-mix(in srgb, var(--status-success) 12%, transparent); - color: var(--status-success); - border-color: color-mix(in srgb, var(--status-success) 60%, transparent); -} - -.badge-info { - background: transparent; - color: var(--brand-primary); - border-color: var(--brand-primary); -} - -.badge-warning { - background: color-mix(in srgb, var(--status-warning) 12%, transparent); - color: var(--status-warning); - border-color: color-mix(in srgb, var(--status-warning) 60%, transparent); -} - -.badge-error { - background: color-mix(in srgb, var(--status-danger-strong) 12%, transparent); - color: var(--status-danger); - border-color: color-mix(in srgb, var(--status-danger-strong) 60%, transparent); -} - -/* Buttons */ -.btn-primary { - background: var(--brand-gradient); - color: var(--on-brand); - padding: 10px 24px; - border-radius: var(--radius-full); - font-weight: 500; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - transition: background var(--transition-normal), box-shadow var(--transition-normal); -} - -.btn-primary:hover:not(:disabled) { - opacity: 0.92; - box-shadow: var(--shadow-glow); -} - -.btn-secondary { - background: var(--bg-tertiary); - color: var(--text-secondary); - border: 1px solid var(--border-subtle); - padding: 10px 20px; - border-radius: var(--radius-full); - font-weight: 500; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - transition: background var(--transition-normal), color var(--transition-normal), border-color var(--transition-normal); -} - -.btn-secondary:hover:not(:disabled) { - background: var(--bg-card-hover); - color: var(--text-primary); - border-color: var(--border-active); -} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 5dff244..024dafc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,7 +2,8 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./App.js"; import "./index.css"; -import { AuthGate, AuthProvider } from "./auth/Auth"; +import { AuthGate } from "./views/auth/AuthView"; +import { AuthProvider } from "./viewmodels/useAuthViewModel"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/frontend/src/models/navigation.ts b/frontend/src/models/navigation.ts index 2514611..ff4b329 100644 --- a/frontend/src/models/navigation.ts +++ b/frontend/src/models/navigation.ts @@ -8,6 +8,7 @@ export const routes = { rag: "/rag", documents: "/documents", admin: "/admin", + ingestion: "/admin/ingestion", } as const; const SEGMENT = "[a-zA-Z0-9_-]+"; diff --git a/frontend/src/models/repoAnalyzer.ts b/frontend/src/models/repoAnalyzer.ts index 148882d..6c80a70 100644 --- a/frontend/src/models/repoAnalyzer.ts +++ b/frontend/src/models/repoAnalyzer.ts @@ -1,4 +1,4 @@ -import type { RepoAnalysis, RepoAnalysisStatus } from "../projects/repo-analyzer.api"; +import type { RepoAnalysis, RepoAnalysisStatus } from "../api/api_repo_analyzer"; export const ANALYSIS_STAGES: ReadonlyArray<{ key: string; label: string }> = [ { key: "ollama", label: "Verificar IA local" }, diff --git a/frontend/src/projects/ProjectArchive.tsx b/frontend/src/projects/ProjectArchive.tsx deleted file mode 100644 index 9e1324f..0000000 --- a/frontend/src/projects/ProjectArchive.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ProjectArchiveView as ProjectArchive } from "../views/projects/ProjectArchiveView"; diff --git a/frontend/src/projects/Projects.tsx b/frontend/src/projects/Projects.tsx deleted file mode 100644 index 1f81746..0000000 --- a/frontend/src/projects/Projects.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ProjectsView as Projects } from "../views/projects/ProjectsView"; diff --git a/frontend/src/projects/navigation.ts b/frontend/src/projects/navigation.ts deleted file mode 100644 index ea8c3ea..0000000 --- a/frontend/src/projects/navigation.ts +++ /dev/null @@ -1 +0,0 @@ -export { routes, isProjectPath, navigate } from "../models/navigation"; diff --git a/frontend/src/projects/projects.css b/frontend/src/projects/projects.css deleted file mode 100644 index 8ac11d0..0000000 --- a/frontend/src/projects/projects.css +++ /dev/null @@ -1 +0,0 @@ -@import "../assets/styles/projects.css"; diff --git a/frontend/src/styles/README.md b/frontend/src/styles/README.md new file mode 100644 index 0000000..6583791 --- /dev/null +++ b/frontend/src/styles/README.md @@ -0,0 +1,15 @@ +# Sistema visual do Sinapse + +O ponto de entrada global é src/index.css. Ele importa, nesta ordem: + +1. tokens.css: fonte única de cores, tipografia, espaçamento, raios e estados. +2. components.css: primitivas ds-* e suas variantes. +3. layout.css: cabeçalho, navegação, estrutura das páginas e regras responsivas. + +Estilos específicos de funcionalidades ficam em src/assets/styles e usam os mesmos tokens. Não devem redefinir botões, badges ou uma paleta paralela. + +Para ações, use Button de views/common/ui com variant primary, secondary, ghost ou danger. Ele compartilha os mesmos estilos em autenticação, projetos, backlog, conhecimento e administração. Use Badge, Field, Alert e os demais componentes compartilhados quando aplicáveis. ds-card--glass é uma variante do mesmo card, sem espaçamento interno, para contêineres que já controlam seu próprio layout. + +As classes garakis, btn-primary, btn-secondary, badge-* e glass-panel foram migradas para ds-*. O stylesheet do protótipo foi removido; a paleta foi preservada nos tokens canônicos. Os imports e testes foram migrados para os caminhos canônicos, permitindo remover os reexports dos diretórios antigos. + +Validação: 195 testes passaram; build aprovado. Inspeção com Chromium/Edge headless em 1440 e 390 px, usando respostas de API simuladas, cobriu projetos, novo projeto, backlog, conhecimento e administração. Nessas páginas não houve erros JavaScript nem overflow horizontal do documento, e os botões usam os mesmos raios e pesos tipográficos. Essa inspeção não substitui integração com backend e banco reais. diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 42d480f..e628614 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -84,6 +84,7 @@ } .ds-card { + padding: var(--space-5); background: var(--bg-card); color: var(--text-primary); border: 1px solid var(--border-subtle); @@ -187,3 +188,23 @@ white-space: nowrap; border: 0; } + +/* Shared layouts and field spacing variants. */ +.ds-grid { display: grid; gap: var(--space-4); } +.ds-grid--one { grid-template-columns: minmax(0, 1fr); } +.ds-grid--two { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.ds-grid--three { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.ds-field--spaced { margin-bottom: var(--space-4); } +.ds-field > label { font-weight: 600; font-size: 0.88rem; color: var(--text-primary); } +@media (max-width: 820px) { + .ds-grid--two, .ds-grid--three { grid-template-columns: minmax(0, 1fr); } +} + +.ds-card--glass { + padding: 0; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + box-shadow: var(--shadow-md); + transition: border-color var(--transition-normal); +} +.ds-card--glass:hover { border-color: var(--border-active); } diff --git a/frontend/src/styles/layout.css b/frontend/src/styles/layout.css new file mode 100644 index 0000000..de4ad12 --- /dev/null +++ b/frontend/src/styles/layout.css @@ -0,0 +1,510 @@ +/* Application shell and page layouts. Primitives live in components.css. */ +button, input, select, textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +h1, h2, h3, h4, h5 { + font-family: "Public Sans", 'Inter', Arial, sans-serif; +} + +h1 { + font-size: 32px; + line-height: 1.15; + letter-spacing: -.03em; + margin: 6px 0; +} + +h2 { + font-size: 18px; + margin: 0 0 14px; +} + +.muted, .help { + color: var(--text-secondary); +} + +.help { + font-size: 12px; +} + +.eyebrow { + font-size: 11px; + font-weight: 800; + letter-spacing: .13em; + color: var(--brand-primary); + text-transform: uppercase; +} + +/* Top Header Bar */ +.top-header { + height: 68px; + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + gap: 30px; + padding: 0 36px; + border-bottom: 1px solid var(--border-subtle); + background: rgba(11, 13, 16, .96); + backdrop-filter: blur(12px); +} + +.brand { + color: #fff; + font-weight: 900; + letter-spacing: .14em; + text-decoration: none; + font-size: 1.2rem; + display: flex; + align-items: center; + gap: 2px; + cursor: pointer; +} + +.brand b { + color: var(--brand-primary); +} + +.nav-links { + display: flex; + gap: 4px; + flex: 1; +} + +.nav-links button, .nav-links a { + padding: 9px 14px; + border-radius: 8px; + color: var(--text-secondary); + text-decoration: none; + background: transparent; + border: 0; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.15s ease; +} + +.nav-links button:hover, .nav-links button.active, +.nav-links a:hover, .nav-links a.active { + background: var(--brand-surface); + color: #fff; +} + +.user-menu-btn, +.user-menu-label { + color: var(--text-secondary); + border: 0; + background: transparent; + white-space: nowrap; + font-weight: 600; + padding: 6px 12px; + border-radius: 8px; +} + +.user-menu-label { + cursor: default; +} + +.user-menu-btn { + transition: background 0.15s ease; +} + +.user-menu-btn:hover { + background: var(--bg-tertiary); + color: #fff; +} + +/* Main Page Container */ +.page-container { + max-width: 1320px; + margin: auto; + padding: 45px 40px 72px; +} + +.head-section { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 20px; + margin-bottom: 26px; +} + +/* Breadcrumbs */ +.crumb-bar { + display: flex; + align-items: center; + gap: 7px; + margin-bottom: 15px; + color: var(--text-secondary); + font-size: 12px; +} + +.crumb-bar button { + border: 0; + background: none; + color: var(--text-secondary); + padding: 0; + cursor: pointer; +} + +.crumb-bar button:hover { + text-decoration: underline; + color: #fff; +} + +.crumb-bar b { + color: #fff; +} + +/* Abas de Projeto */ +.project-tabs { + display: flex; + gap: 4px; + margin: 25px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.project-tabs button { + padding: 11px 16px; + border: 0; + border-bottom: 2px solid transparent; + background: none; + color: var(--text-secondary); + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.project-tabs button.active { + color: #fff; + border-bottom-color: var(--brand-primary); +} + +/* Caixas de Aviso */ +.notice-box { + padding: 13px 15px; + border: 1px solid rgba(64, 151, 255, .42); + border-radius: var(--radius-md); + background: #132e50; + color: #dbeafe; + font-size: 0.9rem; +} + +/* Árvore Hierárquica do Backlog */ +.tree-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; + margin-bottom: 24px; +} + +.tree-grid .ds-card { + min-height: 120px; +} + +/* Card de Qualidade da Especificação */ +.quality-card { + border-left: 3px solid var(--status-success); +} + +/* Documentos & Upload */ +.file-row { + display: flex; + gap: 13px; + align-items: center; + padding: 14px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.file-row:last-child { + border: 0; +} + +.file-icon { + width: 36px; + height: 42px; + display: grid; + place-items: center; + border: 1px solid var(--border-strong); + border-radius: 6px; + color: var(--brand-primary); + font-size: 11px; + font-weight: 800; +} + +/* Resultados de Busca (Conhecimento) */ +.search-result-row { + display: flex; + gap: 14px; + padding: 16px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.search-result-row:last-child { + border: 0; +} + +.result-type-tag { + min-width: 70px; + color: var(--brand-primary); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +/* Conversa / Chat Layout (Estilo Claude / Sinapse) */ +.chat-layout { + display: grid; + grid-template-columns: 240px minmax(0, 800px); + justify-content: center; + gap: 36px; + min-height: 580px; +} + +.chat-history-sidebar { + padding-top: 8px; + border-right: 1px solid var(--border-subtle); + padding-right: 16px; +} + +.chat-history-sidebar h3 { + font-size: 13px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 12px; +} + +.history-item { + display: block; + width: 100%; + margin: 5px 0; + padding: 11px 12px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + text-align: left; + cursor: pointer; + transition: all 0.15s ease; +} + +.history-item:hover, .history-item.active { + background: var(--bg-tertiary); + color: #fff; +} + +.history-item small { + display: block; + margin-top: 3px; + color: var(--text-muted); + font-size: 11px; +} + +.chat-conversation-area { + min-width: 0; +} + +.chat-welcome { + text-align: center; + margin-bottom: 24px; +} + +.chat-welcome h1 { + font-size: 36px; + font-weight: 600; + margin-bottom: 8px; +} + +.chat-banner { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 0 0 16px; + padding: 13px 15px; + border: 1px solid var(--border-subtle); + border-radius: 14px; + background: rgba(23, 26, 33, .6); + color: var(--text-secondary); +} + +.chat-banner b { + color: var(--text-primary); +} + +.composer-box { + margin-top: 20px; + padding: 12px 16px; + border: 1px solid var(--border-strong); + border-radius: 16px; + background: var(--bg-secondary); + box-shadow: 0 10px 35px rgba(0,0,0,.3); +} + +.composer-box input { + width: 100%; + height: 54px; + border: 0; + background: transparent; + color: #fff; + font-size: 15px; + outline: none; +} + +.composer-bottom-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 8px; +} + +.composer-mode-group { + display: inline-flex; + gap: 6px; +} + +.mode-btn { + padding: 5px 10px; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; +} + +.mode-btn.active { + border-color: var(--border-strong); + background: #373737; + color: #fff; +} + +.chat-message-row { + padding: 18px 0; + border-bottom: 1px solid var(--border-subtle); + line-height: 1.65; +} + +.chat-message-row.user { + font-size: 18px; + font-weight: 600; + color: #fff; +} + +.chat-message-row.assistant { + color: #d9dce0; +} + +.citation-source-card { + margin-top: 12px; + padding: 12px 14px; + border: 1px solid var(--border-subtle); + border-radius: 10px; + background: var(--bg-secondary); + font-size: 12px; + color: var(--text-secondary); + line-height: 1.6; +} + +.citation-source-card b { + color: var(--brand-primary); +} + +.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; + } + .user-menu-btn, + .user-menu-label { + display: none; + } + .nav-links { + overflow-x: auto; + } + .page-container { + padding: 24px 16px; + } + .tree-grid { + grid-template-columns: 1fr; + } + .chat-layout { + grid-template-columns: 1fr; + } + .chat-history-sidebar { + border-right: 0; + border-bottom: 1px solid var(--border-subtle); + 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 */ +@media (max-width: 820px) { + .top-header { + height: auto; + min-height: 60px; + flex-wrap: wrap; + gap: 8px 16px; + padding: 10px 16px; + } + + .top-header .nav-links { + order: 3; + flex: 1 1 100%; + overflow-x: auto; + scrollbar-width: none; + padding-bottom: 2px; + } + + .top-header .nav-links button { + white-space: nowrap; + flex: 0 0 auto; + } + + .top-header > div:last-child { + margin-left: auto; + } + + .user-menu-btn, + .user-menu-label { + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + } + + .project-tabs { + overflow-x: auto; + scrollbar-width: thin; + } + + .project-tabs button { + flex: 0 0 auto; + white-space: nowrap; + } +} + +.app-shell { min-height: 100vh; display: flex; flex-direction: column; background: var(--surface-base); color: var(--text-primary); } +.app-user-actions { display: flex; align-items: center; gap: var(--space-3); } +.app-sign-out { min-height: 34px; padding: 0 var(--space-3); font-size: .8rem; } +.app-content { flex: 1; } diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index beb0284..f0da270 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -1,13 +1,13 @@ :root { - /* Fonte única de tokens. Os valores de marca e superfície derivam do protótipo - navegável (assets/styles/garakis-prototype.css) para que a tela tenha uma só - cor de marca; os fallbacks seguem o mesmo protótipo. */ - --surface-base: var(--bg, #0b0d10); - --brand-primary: var(--orange, #ed6a32); + /* Canonical tokens for all application components and layouts. */ + --surface-base: #0b0d10; + --brand-primary: #ed6a32; --brand-primary-hover: color-mix(in srgb, var(--brand-primary) 82%, #000); --text-primary: #ffffff; - --text-secondary: var(--muted, #a6aab3); - --text-muted: var(--dim, #8b93a1); + --text-secondary: #a6aab3; + --text-muted: #8b93a1; + --brand-surface: #4a200f; + --status-info: #4097ff; --on-brand: #17191c; --brand-gradient: linear-gradient(135deg, #f1885b, var(--brand-primary)); @@ -26,23 +26,23 @@ --radius-lg: 12px; --bg-primary: var(--surface-base); - --bg-secondary: var(--surface, #111827); - --bg-tertiary: var(--card, #171a21); - --bg-card: color-mix(in srgb, var(--surface, #111827) 94%, transparent); - --bg-card-hover: color-mix(in srgb, var(--card, #171a21) 82%, #fff); + --bg-secondary: #111827; + --bg-tertiary: #171a21; + --bg-card: color-mix(in srgb, var(--bg-secondary) 94%, transparent); + --bg-card-hover: color-mix(in srgb, var(--bg-tertiary) 82%, #fff); - --border-subtle: var(--line, rgba(196, 199, 204, 0.18)); - --border-strong: var(--strong, rgba(196, 199, 204, 0.3)); + --border-subtle: rgba(196, 199, 204, 0.18); + --border-strong: rgba(196, 199, 204, 0.3); --border-active: color-mix(in srgb, var(--brand-primary) 60%, transparent); --accent-primary: var(--brand-primary); --accent-secondary: color-mix(in srgb, var(--brand-primary) 72%, #fff); --accent-glow: color-mix(in srgb, var(--brand-primary) 28%, transparent); - --status-success: var(--green, #22b458); + --status-success: #22b458; --status-warning: #fbbf24; - --status-danger: var(--red, #f0635d); - --status-danger-strong: var(--red, #f0635d); + --status-danger: #f0635d; + --status-danger-strong: var(--status-danger); --accent-emerald: var(--status-success); --accent-amber: var(--status-warning); diff --git a/frontend/src/viewmodels/useAuthViewModel.tsx b/frontend/src/viewmodels/useAuthViewModel.tsx index 849e177..b5856f1 100644 --- a/frontend/src/viewmodels/useAuthViewModel.tsx +++ b/frontend/src/viewmodels/useAuthViewModel.tsx @@ -20,18 +20,7 @@ export interface AuthContextType { export const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { - const [session, setSessionState] = useState(() => { - try { - const cached = localStorage.getItem(USER_STORAGE_KEY); - if (cached) { - const user = JSON.parse(cached); - return { status: "authenticated", user }; - } - } catch { - // Ignora erro de leitura no storage - } - return { status: "loading" }; - }); + const [session, setSessionState] = useState({ status: "loading" }); const sessionStatus = useRef(session.status); @@ -107,16 +96,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { useEffect(() => { const revalidate = () => { if (sessionStatus.current === "authenticated") { - void restore(true); + void restore(); } }; window.addEventListener("focus", revalidate); - window.addEventListener("popstate", revalidate); return () => { window.removeEventListener("focus", revalidate); - window.removeEventListener("popstate", revalidate); }; }, [restore]); @@ -130,7 +117,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setSession({ status: "authenticated", user }); } - async function register(nome: string, email: string, password: string, role: "admin" | "po" | "dev" = "po") { + async function register(nome: string, email: string, password: string, role: "admin" | "po" | "dev" = "dev") { const current = ++revision.current; const user = await readUser(await apiRequest("/auth/register", { method: "POST", diff --git a/frontend/src/views/admin/AdminView.test.tsx b/frontend/src/views/admin/AdminView.test.tsx new file mode 100644 index 0000000..b59b0e6 --- /dev/null +++ b/frontend/src/views/admin/AdminView.test.tsx @@ -0,0 +1,48 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { AdminView } from "./AdminView"; + +const project = { id: "a0000000-0000-4000-8000-000000000001", nome: "Projeto escolhido", cliente: "Teste", descricao: "", status: "ativo" }; +const json = (body: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(body), { status })); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +it("exige escolha explícita, envia o projeto e informa carga idempotente sem prometer reindexação", async () => { + let loads = 0; + const request = vi.fn((url: string, init?: RequestInit) => { + if (url.includes("/projects?")) return json({ items: [project, { ...project, id: "archived", nome: "Arquivado", status: "arquivado" }], total: 2, limit: 50, offset: 0 }); + if (url.endsWith("/admin/demo-seed")) { + expect(JSON.parse(init!.body as string)).toEqual({ projeto_id: project.id }); + loads++; + return json({ inseridos: loads === 1 ? 2 : 0, message: loads === 1 ? "Dados demonstrativos carregados." : "Este projeto já possui a carga demonstrativa." }); + } + return json({ projetos: 1, epicos: 0, features: 0, pbis: 0, documentos: 0, chunksIndexados: 0, statusSistema: "operacional" }); + }); + vi.stubGlobal("fetch", request); + render(); + const button = screen.getByRole("button", { name: "Carregar demonstração" }); + expect(button).toBeDisabled(); + await screen.findByRole("option", { name: project.nome }); + expect(screen.queryByRole("option", { name: "Arquivado" })).toBeNull(); + expect(button).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Projeto de destino"), { target: { value: project.id } }); + fireEvent.click(button); + await screen.findByText("Dados demonstrativos carregados."); + await waitFor(() => expect(button).toBeEnabled()); + fireEvent.click(button); + await screen.findByText("Este projeto já possui a carga demonstrativa."); + expect(loads).toBe(2); + expect(screen.getByText(/Esta operação não indexa documentos nem gera vetores/)).toBeInTheDocument(); +}); + +it("exibe o erro do backend quando o projeto foi arquivado após a seleção", async () => { + vi.stubGlobal("fetch", vi.fn((url: string) => { + if (url.includes("/projects?")) return json({ items: [project], total: 1, limit: 50, offset: 0 }); + if (url.endsWith("/admin/demo-seed")) return json({ error: "Projeto arquivado é somente leitura." }, 409); + return json({}); + })); + render(); + await screen.findByRole("option", { name: project.nome }); + fireEvent.change(screen.getByLabelText("Projeto de destino"), { target: { value: project.id } }); + fireEvent.click(screen.getByRole("button", { name: "Carregar demonstração" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Projeto arquivado é somente leitura."); +}); diff --git a/frontend/src/views/admin/AdminView.tsx b/frontend/src/views/admin/AdminView.tsx index cb5a7d8..466a0b7 100644 --- a/frontend/src/views/admin/AdminView.tsx +++ b/frontend/src/views/admin/AdminView.tsx @@ -1,6 +1,8 @@ +import { Button } from "../common/ui"; import React, { useState, useEffect } from "react"; -import { apiRequest } from "../../api/api_auth"; -import "../../assets/styles/garakis-prototype.css"; +import { listProjects, type Project } from "../../api/api_projects"; +import { ApiError, apiRequest } from "../../api/api_auth"; +import { navigate } from "../../models/navigation"; interface Stats { projetos: number; @@ -13,6 +15,9 @@ interface Stats { } export const AdminView: React.FC = () => { + const [projects, setProjects] = useState([]); + const [projectId, setProjectId] = useState(""); + const [projectsLoading, setProjectsLoading] = useState(true); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [ingesting, setIngesting] = useState(false); @@ -35,23 +40,44 @@ export const AdminView: React.FC = () => { useEffect(() => { void loadStats(); + const controller = new AbortController(); + async function loadProjects() { + try { + const items: Project[] = []; + let offset = 0; + while (!controller.signal.aborted) { + const page = await listProjects(controller.signal, offset); + items.push(...page.projects.filter(project => project.status !== "arquivado")); + offset += page.limit; + if (offset >= page.total) break; + } + if (!controller.signal.aborted) setProjects(items); + } catch { + if (!controller.signal.aborted) setError("Não foi possível carregar os projetos de destino. Atualize a página para tentar novamente."); + } finally { + if (!controller.signal.aborted) setProjectsLoading(false); + } + } + void loadProjects(); + return () => controller.abort(); }, []); - const handleIngestSeed = async () => { + const handleLoadDemo = async () => { + if (!projectId || ingesting) return; setIngesting(true); - setMessage("Disparando reindexação de acervo no backend..."); + setMessage("Carregando exemplos no projeto selecionado..."); setError(""); try { - const res = await apiRequest("/admin/ingest-seed", { method: "POST" }); + const res = await apiRequest("/admin/demo-seed", { method: "POST", body: JSON.stringify({ projeto_id: projectId }) }); const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Falha na reindexação"); - - setMessage(data.message || "Acervo reindexado com sucesso!"); + setMessage(data.message || "Dados demonstrativos carregados."); void loadStats(); } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Erro ao reindexar acervo."); + const details = err instanceof ApiError ? err.details as { error?: string } | undefined : undefined; + setError(details?.error ?? "Não foi possível carregar os dados demonstrativos."); + setMessage(""); } finally { setIngesting(false); } @@ -61,37 +87,40 @@ export const AdminView: React.FC = () => {
-
OPERACIONALE & GOVERNANÇA
+
OPERAÇÃO & GOVERNANÇA

Administração & Carga Inicial do Acervo

- Monitore contadores reais de entidades persistidas no PostgreSQL e reindexe o acervo vetorial do repositório. + Acompanhe os dados do sistema e carregue exemplos em um projeto escolhido.

+
{message && ( -
+
{message}
)} {error && ( -
+
{error}
)} {/* Grid de Estatísticas */} -
-
+
+
Projetos no Banco -

+

{loading ? "..." : stats?.projetos ?? 0}

Espaços de trabalho cadastrados

-
+
Requisitos & PBIs

{loading ? "..." : stats?.pbis ?? 0} @@ -99,29 +128,37 @@ export const AdminView: React.FC = () => {

PBIs com critérios em BDD

-
- Trechos Indexados (RAG) +
+ Trechos com vetor

{loading ? "..." : stats?.chunksIndexados ?? 0}

-

Vetores bge-m3 no pgvector

+

Somente trechos com embedding armazenado

{/* Painel de Ações Administrativas */} -
+

Operações de Acervo

- Execute a carga inicial dos documentos de referência e reindexação de vetores do ecossistema. + Adicione dois exemplos identificados como demonstração ao projeto escolhido. Repetir a ação não duplica os exemplos. Esta operação não indexa documentos nem gera vetores.

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

Crie um projeto ativo para receber os exemplos.

} +
- - + +
diff --git a/frontend/src/views/admin/IngestionObservabilityView.tsx b/frontend/src/views/admin/IngestionObservabilityView.tsx new file mode 100644 index 0000000..329386f --- /dev/null +++ b/frontend/src/views/admin/IngestionObservabilityView.tsx @@ -0,0 +1,265 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + fetchIngestionSnapshot, + reprocessIngestionDocument, + type IngestionDocument, + type IngestionSnapshot, + type IngestionStatus, +} from "../../api/api_ingestion"; +import { Alert, Badge, Button, EmptyState } from "../common/ui"; +import "../../assets/styles/ingestion.css"; + +const STATUS_LABEL: Record = { + pendente: "Pendente", + processando: "Processando", + processado: "Processado", + falha: "Falha", +}; + +const STATUS_TONE: Record = { + pendente: "warning", + processando: "info", + processado: "success", + falha: "danger", +}; + +const PIPELINE_STEPS: Array<{ key: IngestionStatus; label: string; tooltip: string }> = [ + { key: "pendente", label: "Recebido", tooltip: "Documento gravado, aguardando o worker reivindicar" }, + { key: "processando", label: "Processando", tooltip: "Worker chamou o ai-service e aguarda chunks + embeddings" }, + { key: "processado", label: "Indexado", tooltip: "Chunks persistidos; aparece na busca híbrida S2-06" }, +]; + +function formatBytes(bytes: number | null): string { + if (bytes === null) return "—"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1).replace(".", ",")} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1).replace(".", ",")} MB`; +} + +function formatInstant(value: string | null): string { + if (!value) return "—"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("pt-BR", { dateStyle: "short", timeStyle: "medium" }); +} + +function PipelineBar({ status }: { status: IngestionStatus }) { + const reachedIndex = status === "falha" ? -1 : PIPELINE_STEPS.findIndex((step) => step.key === status); + return ( +
    + {PIPELINE_STEPS.map((step, index) => ( +
  1. +
  2. + ))} + {status === "falha" && ( +
  3. +
  4. + )} +
+ ); +} + +function DocumentRow({ item, canRetry, onRetry, retryingId }: { + item: IngestionDocument; + canRetry: boolean; + onRetry: (item: IngestionDocument) => void; + retryingId: string | null; +}) { + return ( +
+
+
+

{item.nome}

+

+ {item.projeto_nome} + {item.extensao ? ` · ${item.extensao.replace(".", "").toUpperCase()}` : ""} + {" · "} + {formatBytes(item.tamanho_bytes)} +

+
+ {STATUS_LABEL[item.status_processamento]} +
+ + + +
+
Tentativas
{item.processamento_tentativas}
+
Próxima tentativa
{formatInstant(item.processamento_proxima_tentativa)}
+
Atualizado em
{formatInstant(item.updated_at)}
+
+ + {item.processamento_erro && ( + {item.processamento_erro} + )} + + {canRetry && item.status_processamento === "falha" && ( +
+ +
+ )} +
+ ); +} + +export function IngestionObservabilityView() { + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [retrying, setRetrying] = useState(null); + const [actionMessage, setActionMessage] = useState(null); + const scope = useRef(new AbortController()); + + const refresh = useCallback(async (quiet = false) => { + if (scope.current.signal.aborted) return; + if (!quiet) setLoading(true); + try { + const data = await fetchIngestionSnapshot(scope.current.signal); + if (!scope.current.signal.aborted) { + setSnapshot(data); + setError(null); + } + } catch (caught) { + if (!scope.current.signal.aborted) { + setError(caught instanceof Error ? caught.message : "Não foi possível carregar o estado da ingestão."); + } + } finally { + if (!scope.current.signal.aborted && !quiet) setLoading(false); + } + }, []); + + useEffect(() => { + const controller = new AbortController(); + scope.current = controller; + void refresh(false); + const timer = window.setInterval(() => { void refresh(true); }, 5_000); + return () => { + controller.abort(); + window.clearInterval(timer); + }; + }, [refresh]); + + const onRetry = useCallback(async (item: IngestionDocument) => { + setRetrying(item.id); + setActionMessage(null); + try { + await reprocessIngestionDocument(item.projeto_id, item.id); + setActionMessage(`O documento “${item.nome}” foi recolocado na fila. O worker vai pegar no próximo ciclo (≈ a cada 5 s).`); + await refresh(true); + } catch (caught) { + setActionMessage(caught instanceof Error ? caught.message : "Não foi possível reagendar o documento."); + } finally { + setRetrying(null); + } + }, [refresh]); + + if (loading && !snapshot) { + return ( +
+

Pipeline de ingestão

+
Carregando estado da fila…
+
+ ); + } + + if (error && !snapshot) { + return ( +
+

Pipeline de ingestão

+ {error} + +
+ ); + } + + if (!snapshot) return null; + + const totalMovimentando = snapshot.counts.pendente + snapshot.counts.processando; + + return ( +
+
+
+
ADMIN · PIPELINE
+

Ingestão de documentos

+

+ Visão em tempo real do worker da S2-01 processando os uploads. Equivale ao + editor do n8n: aqui você vê cada documento chegar, ser processado, indexado + ou falhar, sem precisar abrir uma segunda ferramenta. +

+
+
+ + Atualizado em {formatInstant(snapshot.generated_at)} · auto-refresh 5 s + + +
+
+ + {actionMessage && {actionMessage}} + {error && Falha ao atualizar: {error}} + +
+ {(["pendente", "processando", "processado", "falha"] as IngestionStatus[]).map((status) => ( +
+ {snapshot.counts[status]} + {STATUS_LABEL[status]} +
+ ))} +
+ +
+

Em andamento ({totalMovimentando})

+ {snapshot.active.length === 0 ? ( + + ) : ( +
+ {snapshot.active.map((item) => ( + + ))} +
+ )} +
+ + {snapshot.failed.length > 0 && ( +
+

Falhas recentes ({snapshot.failed.length})

+
+ {snapshot.failed.map((item) => ( + + ))} +
+
+ )} + +
+

Últimas atualizações

+ {snapshot.recent.length === 0 ? ( + + ) : ( +
+ {snapshot.recent.map((item) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/auth/Auth.test.tsx b/frontend/src/views/auth/Auth.test.tsx similarity index 77% rename from frontend/src/auth/Auth.test.tsx rename to frontend/src/views/auth/Auth.test.tsx index 9ab5af7..fe98080 100644 --- a/frontend/src/auth/Auth.test.tsx +++ b/frontend/src/views/auth/Auth.test.tsx @@ -1,9 +1,10 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { AuthGate, AuthProvider, useAuth } from "./Auth"; -import { navigate, safeDestination } from "./navigation"; -import { apiRequest, readUser } from "../api/api_auth"; +import { AuthGate } from "./AuthView"; +import { AuthProvider, useAuth } from "../../viewmodels/useAuthViewModel"; +import { navigate, safeDestination } from "../../models/navigation"; +import { apiRequest, readUser } from "../../api/api_auth"; import { useLayoutEffect, useRef } from "react"; const user = { id: "1", nome: "Pessoa", role: "po", email: "pessoa@example.com" }; @@ -129,7 +130,7 @@ it.each([ expect(screen.queryByText("Conteúdo interno")).toBeNull(); }); -it.each(["focus", "navigation", "history"])("informa expiração detectada por %s e preserva o destino", async trigger => { +it.each(["focus"])("informa expiração detectada por %s e preserva o destino", async trigger => { const fetchMock = vi.fn().mockImplementationOnce(() => reply(200, { user })).mockImplementationOnce(() => reply(401)); vi.stubGlobal("fetch", fetchMock); mount(); @@ -146,14 +147,16 @@ it.each(["focus", "navigation", "history"])("informa expiração detectada por % expect(fetchMock.mock.calls[1][0]).toBe("/api/v1/auth/me"); }); -it("oculta conteúdo até a revalidação da navegação terminar", async () => { +it("mantém conteúdo e estado durante a revalidação em segundo plano", async () => { let resolve!: (response: Response) => void; vi.stubGlobal("fetch", vi.fn().mockImplementationOnce(() => reply(200, { user })) .mockImplementationOnce(() => new Promise(done => { resolve = done; }))); mount(); - fireEvent.click(await screen.findByText("Requisitos")); - expect(screen.queryByText("Conteúdo interno")).toBeNull(); - expect(screen.getByRole("status").textContent).toBe("Verificando sessão…"); + await screen.findByText("Conteúdo interno"); + const content = screen.getByText("Conteúdo interno"); + fireEvent.focus(window); + expect(screen.getByText("Conteúdo interno")).toBe(content); + expect(screen.queryByRole("status")).toBeNull(); resolve(new Response(JSON.stringify({ user }))); expect(await screen.findByText("Conteúdo interno")).toBeTruthy(); }); @@ -165,7 +168,7 @@ it("não mostra aviso de expiração no primeiro acesso anônimo", async () => { expect(screen.queryByText("Sua sessão expirou. Entre novamente para continuar.")).toBeNull(); }); -it("revalida histórico imediatamente após restaurar a sessão, antes dos efeitos passivos", async () => { +it("navegação pelo histórico não dispara nova verificação de sessão", async () => { const fetchMock = vi.fn().mockImplementationOnce(() => reply(200, { user })).mockImplementationOnce(() => reply(401)); vi.stubGlobal("fetch", fetchMock); function ImmediateNavigation() { @@ -179,7 +182,37 @@ it("revalida histórico imediatamente após restaurar a sessão, antes dos efeit return ; } render(); - expect(await screen.findByText("Sua sessão expirou. Entre novamente para continuar.")).toBeTruthy(); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(await screen.findByText("Conteúdo interno")).toBeTruthy(); + fireEvent.click(screen.getByText("Requisitos")); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("status")).toBeNull(); +}); + + +it("cadastro público envia apenas perfil dev", async () => { + window.history.replaceState(null, "", "/register"); + const fetchMock = vi.fn().mockImplementationOnce(() => reply(401)).mockImplementation(() => reply(200, {user: {...user, role: "dev"}})); + vi.stubGlobal("fetch", fetchMock); + mount(); + await screen.findByText("Criar nova conta"); + expect(screen.queryByRole("combobox")).toBeNull(); + fireEvent.change(screen.getByLabelText("Nome Completo"), {target: {value: "Pessoa"}}); + fireEvent.change(screen.getByLabelText("E-mail Corporativo"), {target: {value: "pessoa@example.com"}}); + fireEvent.change(screen.getByLabelText("Senha (mínimo 6 caracteres)"), {target: {value: "Senha123!"}}); + fireEvent.change(screen.getByLabelText("Confirmar Senha"), {target: {value: "Senha123!"}}); + fireEvent.click(screen.getByText("Cadastrar e Entrar")); + await waitFor(() => expect(fetchMock.mock.calls.some(([url, init]) => url === "/api/v1/auth/register" && JSON.parse(init.body).role === "dev")).toBe(true)); +}); + + +it("cache não autentica antes da resposta inicial e falha de rede bloqueia a abertura", async () => { + localStorage.setItem("app_auth_user",JSON.stringify(user)); + let reject!: (reason: Error) => void; + vi.stubGlobal("fetch",vi.fn(()=>new Promise((_resolve,fail)=>{reject=fail}))); + mount(); + expect(screen.queryByText("Conteúdo interno")).toBeNull(); + expect(screen.getByRole("status")).toHaveProperty("textContent","Verificando sessão…"); + reject(new Error("offline")); + expect(await screen.findByText("Não foi possível verificar a sessão")).toBeTruthy(); expect(screen.queryByText("Conteúdo interno")).toBeNull(); }); diff --git a/frontend/src/views/auth/AuthView.tsx b/frontend/src/views/auth/AuthView.tsx index 119e5b1..39b2c2c 100644 --- a/frontend/src/views/auth/AuthView.tsx +++ b/frontend/src/views/auth/AuthView.tsx @@ -1,3 +1,4 @@ +import { Button } from "../common/ui"; import { useEffect, useState, type ReactNode } from "react"; import { ApiError } from "../../api/api_auth"; import { useAuth } from "../../viewmodels/useAuthViewModel"; @@ -26,11 +27,11 @@ export function AuthGate({ children }: { children: ReactNode }) { if (session.status === "loading") return
Verificando sessão…
; - if (session.status === "error") return
+ if (session.status === "error") return

Não foi possível verificar a sessão

{notice || "Verifique sua conexão e tente novamente."}

- - + +
; if (session.status === "anonymous") return isAuthRoute ? : null; @@ -51,28 +52,28 @@ export function AuthScreen({ initialMode }: { initialMode: "login" | "register" return (
-
+
{mode === "register" && ( - + )} {mode === "login" && ( - + )}
@@ -124,9 +125,9 @@ export function LoginOnCard() { setPassword(e.target.value)} disabled={busy} placeholder="••••••••" /> {error &&

{error}

} - + ); @@ -138,7 +139,6 @@ export function RegisterOnCard({ onSwitch }: { onSwitch: () => void }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); - const [role, setRole] = useState<"po" | "dev">("po"); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); @@ -165,7 +165,7 @@ export function RegisterOnCard({ onSwitch }: { onSwitch: () => void }) { setBusy(true); setError(""); try { - await register(nome.trim(), email.trim(), password, role); + await register(nome.trim(), email.trim(), password, "dev"); } catch (failure) { setError( failure instanceof ApiError && failure.status === 409 @@ -184,11 +184,7 @@ export function RegisterOnCard({ onSwitch }: { onSwitch: () => void }) { setEmail(e.target.value)} disabled={busy} placeholder="seu.email@empresa.com" /> - - +

Novas contas recebem o perfil Desenvolvedor. O acesso de PO é concedido por um administrador.

setPassword(e.target.value)} disabled={busy} placeholder="••••••••" /> @@ -197,7 +193,7 @@ export function RegisterOnCard({ onSwitch }: { onSwitch: () => void }) { setConfirmPassword(e.target.value)} disabled={busy} placeholder="••••••••" /> {error &&

{error}

} - +
diff --git a/frontend/src/backlog/Backlog.test.tsx b/frontend/src/views/backlog/Backlog.test.tsx similarity index 98% rename from frontend/src/backlog/Backlog.test.tsx rename to frontend/src/views/backlog/Backlog.test.tsx index 62b3dcb..3182f46 100644 --- a/frontend/src/backlog/Backlog.test.tsx +++ b/frontend/src/views/backlog/Backlog.test.tsx @@ -7,8 +7,8 @@ import { screen, waitFor, } from "@testing-library/react"; -import { Projects as ProjectsPage } from "../projects/Projects"; -import { parseBacklogRoute } from "./navigation"; +import { ProjectsView as ProjectsPage } from "../projects/ProjectsView"; +import { parseBacklogRoute } from "../../models/navigation"; const response = (body: unknown, status = 200) => Promise.resolve( diff --git a/frontend/src/views/backlog/BacklogSearchResults.tsx b/frontend/src/views/backlog/BacklogSearchResults.tsx index ac5d10e..41e73c2 100644 --- a/frontend/src/views/backlog/BacklogSearchResults.tsx +++ b/frontend/src/views/backlog/BacklogSearchResults.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from "react"; import { describeSearchError, searchBacklog, type BacklogSearchResult } from "../../api/api_backlog_search"; +import "../../assets/styles/backlog-search.css"; import { TYPE_LABEL, highlightParts, nodeHref, summarize } from "../../models/backlogSearch"; import { navigate } from "../../models/navigation"; import { Alert, Badge, Button, EmptyState } from "../common/ui"; -import "../../assets/styles/backlog-search.css"; type State = | { state: "loading" } @@ -50,12 +50,12 @@ export function BacklogSearchResults({ }, [projectId, query, status, technologyId, attempt]); if (state.state === "loading") { - return
Buscando no backlog…
; + return
Buscando no backlog…
; } if (state.state === "error") { return ( -
+

{state.message}

@@ -66,7 +66,7 @@ export function BacklogSearchResults({ if (result.items.length === 0) { return ( -
+
+
  • {TYPE_LABEL[item.tipo]} {item.codigo && {item.codigo}} diff --git a/frontend/src/backlog/BacklogTechnologySelector.test.tsx b/frontend/src/views/backlog/BacklogTechnologySelector.test.tsx similarity index 95% rename from frontend/src/backlog/BacklogTechnologySelector.test.tsx rename to frontend/src/views/backlog/BacklogTechnologySelector.test.tsx index 41bebf3..3e42615 100644 --- a/frontend/src/backlog/BacklogTechnologySelector.test.tsx +++ b/frontend/src/views/backlog/BacklogTechnologySelector.test.tsx @@ -2,7 +2,7 @@ import { afterEach, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { BacklogTechnologySelector } from "../views/backlog/BacklogTechnologySelector"; +import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; afterEach(() => { cleanup(); diff --git a/frontend/src/views/backlog/BacklogTechnologySelector.tsx b/frontend/src/views/backlog/BacklogTechnologySelector.tsx index a068779..a811bd0 100644 --- a/frontend/src/views/backlog/BacklogTechnologySelector.tsx +++ b/frontend/src/views/backlog/BacklogTechnologySelector.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { listTechnologies, type TechnologyOption } from "../../api/api_backlog"; +import { Button } from "../common/ui"; type LoadState = | { state: "loading" } @@ -46,9 +47,9 @@ export function BacklogTechnologySelector({ {result.state === "error" && (

    Não foi possível carregar o catálogo de tecnologias.

    - +
    )} {result.state === "ready" && result.items.length === 0 && ( diff --git a/frontend/src/views/backlog/BacklogTreeNodes.tsx b/frontend/src/views/backlog/BacklogTreeNodes.tsx new file mode 100644 index 0000000..f204253 --- /dev/null +++ b/frontend/src/views/backlog/BacklogTreeNodes.tsx @@ -0,0 +1,341 @@ +import { + type BacklogEpicNode, + type BacklogFeatureNode, + type BacklogPbiNode +} from "../../api/api_backlog_tree"; +import { + navigate, +} from "../../models/navigation"; + +const statusLabels: + Record = { + rascunho: "Rascunho", + ativo: "Ativo", + pronto: "Pronto", + concluido: "Concluído", + arquivado: "Arquivado", +}; + +function Technologies({ + values, +}: { + values: + Array<{ + id: string; + nome: string; + }>; +}) { + if (values.length === 0) { + return null; + } + + return ( + + {values.map( + (item) => ( + + {item.nome} + + ), + )} + + ); +} + +function ItemMeta({ + item, +}: { + item: { + status: string; + + tecnologias: + Array<{ + id: string; + nome: string; + }>; + }; +}) { + return ( + + + {statusLabels[item.status] + ?? item.status} + + + + + ); +} + +function TreePbi({ + projectId, + epicId, + featureId, + pbi, +}: { + projectId: string; + epicId: string; + featureId: string; + pbi: BacklogPbiNode; +}) { + const path = + `/projects/${projectId}` + + `/epics/${epicId}` + + `/features/${featureId}` + + `/pbis/${pbi.id}`; + + return ( +
  • + + + + + +
  • + ); +} + +function TreeFeature({ + projectId, + epicId, + feature, + open, + forcedOpen, + onToggle, +}: { + projectId: string; + epicId: string; + feature: BacklogFeatureNode; + open: boolean; + forcedOpen: boolean; + onToggle: () => void; +}) { + const isOpen = + forcedOpen || open; + + const path = + `/projects/${projectId}` + + `/epics/${epicId}` + + `/features/${feature.id}`; + + return ( +
  • +
    + + + + + + + +
    + + {isOpen && ( + feature.pbis.length > 0 + ? ( +
      + {feature.pbis.map( + (pbi) => ( + + ), + )} +
    + ) + : ( +

    + Nenhum PBI corresponde + nesta feature. +

    + ) + )} +
  • + ); +} + +export function TreeEpic({ + projectId, + epic, + open, + forcedOpen, + expandedFeatures, + onToggle, + onToggleFeature, +}: { + projectId: string; + epic: BacklogEpicNode; + open: boolean; + forcedOpen: boolean; + expandedFeatures: Set; + onToggle: () => void; + onToggleFeature: + (id: string) => void; +}) { + const isOpen = + forcedOpen || open; + + return ( +
  • +
    + + + + + + + +
    + + {isOpen && ( + epic.features.length > 0 + ? ( +
      + {epic.features.map( + (feature) => ( + + onToggleFeature( + feature.id, + ) + } + /> + ), + )} +
    + ) + : ( +

    + Nenhuma feature + corresponde neste épico. +

    + ) + )} +
  • + ); +} diff --git a/frontend/src/views/backlog/BacklogTreeView.tsx b/frontend/src/views/backlog/BacklogTreeView.tsx index d685096..1e47301 100644 --- a/frontend/src/views/backlog/BacklogTreeView.tsx +++ b/frontend/src/views/backlog/BacklogTreeView.tsx @@ -3,553 +3,37 @@ import { useMemo, useState, } from "react"; - import { ApiError, } from "../../api/api_auth"; - +import { MIN_QUERY_LENGTH } from "../../api/api_backlog_search"; import { getProjectBacklogTree, - type BacklogEpicNode, - type BacklogFeatureNode, - type BacklogPbiNode, - type ProjectBacklogTree, + type ProjectBacklogTree } from "../../api/api_backlog_tree"; - +import { isSearchQuery } from "../../models/backlogSearch"; import { navigate, } from "../../models/navigation"; - -import { MIN_QUERY_LENGTH } from "../../api/api_backlog_search"; -import { isSearchQuery } from "../../models/backlogSearch"; +import { Button } from "../common/ui"; import { BacklogSearchResults } from "./BacklogSearchResults"; - -export interface BacklogFilters { - status: string; - technologyId: string; - query: string; -} - -const MAX_QUERY_LENGTH = 100; - -type TreeFilters = Pick; +import { TreeEpic } from "./BacklogTreeNodes"; +import { EMPTY_FILTERS, MAX_QUERY_LENGTH, STATUS_OPTIONS, filterBacklogTree, readFilters, storageKey, type BacklogFilters } from "./backlogTreeFilters"; +export { filterBacklogTree } from "./backlogTreeFilters"; +export type { BacklogFilters } from "./backlogTreeFilters"; type Result = | { - state: "loading"; - } - | { - state: "error"; - message: string; - } - | { - state: "ready"; - tree: ProjectBacklogTree; - }; - -const EMPTY_FILTERS: - BacklogFilters = { - status: "", - technologyId: "", - query: "", - }; - -const STATUS_OPTIONS = [ - { value: "rascunho", label: "Rascunho" }, - { value: "ativo", label: "Ativo" }, - { value: "concluido", label: "Concluído" }, - { value: "arquivado", label: "Arquivado" }, -] as const; -const VALID_STATUSES = new Set([ - "", - ...STATUS_OPTIONS.map(({ value }) => value), -]); - -function storageKey( - projectId: string, -) { - return ( - "sinapse.backlog.filters." - + projectId - ); -} - -function readFilters( - projectId: string, -): BacklogFilters { - try { - const stored = - window.sessionStorage - .getItem( - storageKey(projectId), - ); - - if (!stored) { - return EMPTY_FILTERS; - } - - const value = - JSON.parse( - stored, - ) as Partial; - - return { - status: - typeof value.status - === "string" - && VALID_STATUSES.has( - value.status, - ) - ? value.status - : "", - - technologyId: - typeof value.technologyId - === "string" - ? value.technologyId - : "", - - query: - typeof value.query - === "string" - ? value.query.slice(0, MAX_QUERY_LENGTH) - : "", - }; - } catch { - return EMPTY_FILTERS; - } -} - -function matches( - item: { - status: string; - - tecnologias: - Array<{ - id: string; - }>; - }, - - filters: TreeFilters, -) { - return ( - ( - !filters.status - || item.status - === filters.status - ) - && ( - !filters.technologyId - || item.tecnologias.some( - (technology) => - technology.id - === filters.technologyId, - ) - ) - ); -} - -export function filterBacklogTree( - tree: ProjectBacklogTree, - filters: TreeFilters, -): ProjectBacklogTree { - if ( - !filters.status - && !filters.technologyId - ) { - return tree; + state: "loading"; } - - const epics = - tree.epics.flatMap( - (epic) => { - const features = - epic.features.flatMap( - (feature) => { - const pbis = - feature.pbis.filter( - (pbi) => - matches( - pbi, - filters, - ), - ); - - return ( - matches( - feature, - filters, - ) - || pbis.length > 0 - ) - ? [ - { - ...feature, - pbis, - }, - ] - : []; - }, - ); - - return ( - matches( - epic, - filters, - ) - || features.length > 0 - ) - ? [ - { - ...epic, - features, - }, - ] - : []; - }, - ); - - return { - ...tree, - epics, - }; -} - -const statusLabels: - Record = { - rascunho: "Rascunho", - ativo: "Ativo", - pronto: "Pronto", - concluido: "Concluído", - arquivado: "Arquivado", - }; - -function Technologies({ - values, -}: { - values: - Array<{ - id: string; - nome: string; - }>; -}) { - if (values.length === 0) { - return null; + | { + state: "error"; + message: string; } - - return ( - - {values.map( - (item) => ( - - {item.nome} - - ), - )} - - ); -} - -function ItemMeta({ - item, -}: { - item: { - status: string; - - tecnologias: - Array<{ - id: string; - nome: string; - }>; + | { + state: "ready"; + tree: ProjectBacklogTree; }; -}) { - return ( - - - {statusLabels[item.status] - ?? item.status} - - - - - ); -} - -function TreePbi({ - projectId, - epicId, - featureId, - pbi, -}: { - projectId: string; - epicId: string; - featureId: string; - pbi: BacklogPbiNode; -}) { - const path = - `/projects/${projectId}` - + `/epics/${epicId}` - + `/features/${featureId}` - + `/pbis/${pbi.id}`; - - return ( -
  • - - - - - -
  • - ); -} - -function TreeFeature({ - projectId, - epicId, - feature, - open, - forcedOpen, - onToggle, -}: { - projectId: string; - epicId: string; - feature: BacklogFeatureNode; - open: boolean; - forcedOpen: boolean; - onToggle: () => void; -}) { - const isOpen = - forcedOpen || open; - - const path = - `/projects/${projectId}` - + `/epics/${epicId}` - + `/features/${feature.id}`; - - return ( -
  • -
    - - - - - - - -
    - - {isOpen && ( - feature.pbis.length > 0 - ? ( -
      - {feature.pbis.map( - (pbi) => ( - - ), - )} -
    - ) - : ( -

    - Nenhum PBI corresponde - nesta feature. -

    - ) - )} -
  • - ); -} - -function TreeEpic({ - projectId, - epic, - open, - forcedOpen, - expandedFeatures, - onToggle, - onToggleFeature, -}: { - projectId: string; - epic: BacklogEpicNode; - open: boolean; - forcedOpen: boolean; - expandedFeatures: Set; - onToggle: () => void; - onToggleFeature: - (id: string) => void; -}) { - const isOpen = - forcedOpen || open; - - return ( -
  • -
    - - - - - - - -
    - - {isOpen && ( - epic.features.length > 0 - ? ( -
      - {epic.features.map( - (feature) => ( - - onToggleFeature( - feature.id, - ) - } - /> - ), - )} -
    - ) - : ( -

    - Nenhuma feature - corresponde neste épico. -

    - ) - )} -
  • - ); -} export function BacklogTreeView({ projectId, @@ -625,7 +109,7 @@ export function BacklogTreeView({ message: error instanceof ApiError - && error.status === 404 + && error.status === 404 ? "Projeto não encontrado." : "Não foi possível carregar a árvore do backlog.", }); @@ -687,9 +171,9 @@ export function BacklogTreeView({ () => result.state === "ready" ? filterBacklogTree( - result.tree, - filters, - ) + result.tree, + filters, + ) : null, [ result, @@ -778,8 +262,8 @@ export function BacklogTreeView({
    {canCreate && ( - + )}
    @@ -828,7 +312,7 @@ export function BacklogTreeView({ {filters.query.trim().length > 0 && !searchMode && filters.query.trim().length - < MIN_QUERY_LENGTH + < MIN_QUERY_LENGTH ? `Digite ao menos ${MIN_QUERY_LENGTH} caracteres.` : "Busca apenas neste projeto, combinada com os filtros."} @@ -917,15 +401,15 @@ export function BacklogTreeView({ {filtersActive && ( - + )}
    )} @@ -933,7 +417,7 @@ export function BacklogTreeView({ {result.state === "loading" && (
    Carregando árvore @@ -943,13 +427,13 @@ export function BacklogTreeView({ {result.state === "error" && ( -
    +

    {result.message}

    - +
    )} @@ -996,7 +480,7 @@ export function BacklogTreeView({ && result.tree.epics .length === 0 && ( -
    +

    Este projeto ainda não possui épicos @@ -1009,8 +493,8 @@ export function BacklogTreeView({

    {canCreate && ( - + )}

    )} @@ -1031,7 +515,7 @@ export function BacklogTreeView({ && filteredTree?.epics .length === 0 && ( -
    +

    Nenhum item corresponde aos filtros @@ -1043,13 +527,13 @@ export function BacklogTreeView({ ou limpe os critérios.

    - +

    )} diff --git a/frontend/src/views/backlog/BacklogView.tsx b/frontend/src/views/backlog/BacklogView.tsx index 74a3211..21d3adb 100644 --- a/frontend/src/views/backlog/BacklogView.tsx +++ b/frontend/src/views/backlog/BacklogView.tsx @@ -1,8 +1,11 @@ import type { BacklogRoute } from "../../models/navigation"; import { navigate } from "../../models/navigation"; -import { EpicForm, EpicDetail } from "./EpicsView"; -import { FeatureList, FeatureForm, FeatureDetail } from "./FeaturesView"; -import { PbiList, PbiForm, PbiDetail } from "./PbisView"; +import { Button } from "../common/ui"; +import { EpicDetail, EpicForm } from "./EpicsView"; +import { FeatureDetail, FeatureForm, FeatureList } from "./FeaturesView"; +import { PbiDetail } from "./PbiDetail"; +import { PbiForm } from "./PbiForm"; +import { PbiList } from "./PbiList"; export function BacklogScreen({ route, canCreate }: { route: Exclude; canCreate: boolean }) { if (route.screen === "epic-new") { @@ -45,7 +48,7 @@ function SomenteLeitura({ voltar }: { voltar: string }) {

    Acesso de leitura

    Seu perfil não permite criar itens do backlog.

    - +
    ); } diff --git a/frontend/src/views/backlog/CriteriaView.tsx b/frontend/src/views/backlog/CriteriaView.tsx index 18c31b8..b217498 100644 --- a/frontend/src/views/backlog/CriteriaView.tsx +++ b/frontend/src/views/backlog/CriteriaView.tsx @@ -1,14 +1,15 @@ import { useEffect, useRef, useState } from "react"; import { ApiError } from "../../api/api_auth"; import { - listCriteria, createCriterion, deleteCriterion, + listCriteria, moveCriterion, type Criterion, type CriterionEntityType, } from "../../api/api_backlog"; import "../../assets/styles/projects.css"; +import { Button } from "../common/ui"; type Result = | { state: "loading" } @@ -95,7 +96,7 @@ export function CriteriaEditor({ state: "error", message: error instanceof ApiError && - error.status === 401 + error.status === 401 ? "É necessário entrar para ver os critérios." : "Não foi possível carregar os critérios.", }); @@ -146,22 +147,22 @@ export function CriteriaEditor({ const criado = await createCriterion( isCenario ? { - entidade_tipo: "pbi", - entidade_id: entidadeId, - ...cenarioForm, - ...(justificativaObrigatoria - ? { justificativa: justificativa.trim() } - : {}), - } + entidade_tipo: "pbi", + entidade_id: entidadeId, + ...cenarioForm, + ...(justificativaObrigatoria + ? { justificativa: justificativa.trim() } + : {}), + } : { - entidade_tipo: - entidadeTipo as "epico" | "feature", - entidade_id: entidadeId, - ...textoForm, - ...(justificativaObrigatoria - ? { justificativa: justificativa.trim() } - : {}), - }, + entidade_tipo: + entidadeTipo as "epico" | "feature", + entidade_id: entidadeId, + ...textoForm, + ...(justificativaObrigatoria + ? { justificativa: justificativa.trim() } + : {}), + }, ); versaoRef.current += 1; @@ -297,19 +298,19 @@ export function CriteriaEditor({
    {canEdit && !formOpen && ( - + )}
    @@ -338,7 +339,7 @@ export function CriteriaEditor({ {result.state === "loading" && (
    Carregando… @@ -346,21 +347,21 @@ export function CriteriaEditor({ )} {result.state === "error" && ( -
    +

    {result.message}

    - +
    )} {result.state === "ready" && (result.items.length === 0 && !formOpen ? ( -
    +

    Nenhum{" "} {isCenario @@ -391,7 +392,7 @@ export function CriteriaEditor({ ? -1 : undefined } - className="glass-panel project-card" + className="ds-card ds-card--glass project-card" key={item.id} > {isCenario ? ( @@ -415,8 +416,8 @@ export function CriteriaEditor({ {canEdit && (

    - + - + - +
    )} @@ -463,7 +464,7 @@ export function CriteriaEditor({ {canEdit && formOpen && (
    @@ -579,19 +580,19 @@ export function CriteriaEditor({ )}
    - + - +
    )} diff --git a/frontend/src/views/backlog/DecisionsPanel.tsx b/frontend/src/views/backlog/DecisionsPanel.tsx index a0be54c..56e29e3 100644 --- a/frontend/src/views/backlog/DecisionsPanel.tsx +++ b/frontend/src/views/backlog/DecisionsPanel.tsx @@ -8,8 +8,8 @@ import { type DecisionKind, type DecisionList, } from "../../api/api_decisions"; -import { Alert, Badge, Button, EmptyState } from "../common/ui"; import "../../assets/styles/decisions.css"; +import { Alert, Badge, Button, EmptyState } from "../common/ui"; const LEVEL_LABEL: Record = { projeto: "Projeto", @@ -159,7 +159,7 @@ export function DecisionsPanel({ const inherited = state.state === "ready" ? state.data.decisoes.filter((item) => item.origem.herdada) : []; return ( -
    +

    Decisões

    @@ -187,13 +187,13 @@ export function DecisionsPanel({ ["alternativas", "Alternativas consideradas e descartadas (opcional)", "textarea"], ] as const ).map(([field, label, control]) => ( -
    +
    {control === "input" ? ( Promise.resolve(new Response(JSON.stringify(body), { status })); const Projects = ({ pathname }: { pathname: string }) => ; diff --git a/frontend/src/backlog/Epics.test.tsx b/frontend/src/views/backlog/Epics.test.tsx similarity index 97% rename from frontend/src/backlog/Epics.test.tsx rename to frontend/src/views/backlog/Epics.test.tsx index 2954c90..07825da 100644 --- a/frontend/src/backlog/Epics.test.tsx +++ b/frontend/src/views/backlog/Epics.test.tsx @@ -1,7 +1,7 @@ import { afterEach, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { EpicDetail, EpicForm, EpicList } from "./Epics"; -import { FeatureList } from "./Features"; +import { EpicDetail, EpicForm, EpicList } from "./EpicsView"; +import { FeatureList } from "./FeaturesView"; const epic = { id: "epic-1", projeto_id: "project-1", titulo: "Organizar requisitos", status: "rascunho", descricao: "d", objetivo: "o", escopo_macro: "e", resultado_esperado: "r", criterios_count: 1 }; diff --git a/frontend/src/views/backlog/EpicsView.tsx b/frontend/src/views/backlog/EpicsView.tsx index 8ca417f..22bec10 100644 --- a/frontend/src/views/backlog/EpicsView.tsx +++ b/frontend/src/views/backlog/EpicsView.tsx @@ -1,16 +1,17 @@ -import { ReadOnlyContext } from "../../models/ReadOnlyContext"; -import { ItemArchiveView } from "./ItemArchiveView"; import { useContext, useEffect, useRef, useState, type ReactNode } from "react"; import { ApiError } from "../../api/api_auth"; -import { navigate } from "../../models/navigation"; -import { createEpic, completeEpic, updateEpic, getEpic, listEpics, camposFaltantesDe, type Epic, type EpicInput } from "../../api/api_backlog"; +import { camposFaltantesDe, completeEpic, createEpic, getEpic, listEpics, updateEpic, type Epic, type EpicInput } from "../../api/api_backlog"; +import "../../assets/styles/projects.css"; import { descreverCamposFaltantes } from "../../models/fields"; -import { useUnsavedChangesGuard } from "../../viewmodels/useUnsavedChangesGuard"; +import { navigate } from "../../models/navigation"; +import { ReadOnlyContext } from "../../models/ReadOnlyContext"; import { usePbiQualityConfiguration } from "../../viewmodels/usePbiQualityConfiguration"; -import { CriteriaEditor } from "./CriteriaView"; +import { useUnsavedChangesGuard } from "../../viewmodels/useUnsavedChangesGuard"; +import { Button } from "../common/ui"; import { BacklogBreadcrumb } from "./BacklogBreadcrumb"; import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; -import "../../assets/styles/projects.css"; +import { CriteriaEditor } from "./CriteriaView"; +import { ItemArchiveView } from "./ItemArchiveView"; type ListResult = { state: "loading" } | { state: "error"; message: string } | { state: "ready"; epics: Epic[] }; const emptyInput: EpicInput = { projeto_id: "", titulo: "", descricao: "", objetivo: "", escopo_macro: "", resultado_esperado: "", tecnologias_ids: [] }; @@ -38,21 +39,21 @@ export function EpicList({ projetoId, canCreate: allowedToCreate }: { projetoId:

    Épicos do projeto

    Épicos

    - {canCreate && } + {canCreate && }
    - {result.state === "loading" &&
    Carregando épicos…
    } - {result.state === "error" &&

    {result.message}

    -
    } + {result.state === "loading" &&
    Carregando épicos…
    } + {result.state === "error" &&

    {result.message}

    +
    } {result.state === "ready" && (result.epics.length === 0 - ?

    Nenhum épico cadastrado

    Crie o primeiro épico para começar a especificar este projeto.

    - {canCreate && }
    + ?

    Nenhum épico cadastrado

    Crie o primeiro épico para começar a especificar este projeto.

    + {canCreate && }
    :
    {result.epics.map((epic) => ( -
    - {epic.status} +
    + {epic.status}

    {epic.titulo}

    {epic.objetivo || "Sem objetivo registrado."}

    - +
    ))}
    )}
    @@ -73,7 +74,7 @@ export function EpicForm({ projetoId }: { projetoId: string }) {

    Épicos / Novo épico

    Criar épico

    Apenas o título é obrigatório para salvar como rascunho. Os demais campos do guia são exigidos para concluir.

    -
    { + { event.preventDefault(); if (submitting.current) return; if (!values.titulo.trim()) { setMessage("Informe o título do épico."); return; } @@ -107,16 +108,16 @@ export function EpicForm({ projetoId }: { projetoId: string }) { /> {message &&

    {message}

    }
    - - + +
    ); } -import { ItemHistoryView } from "./ItemHistoryView"; import { DecisionsPanel } from "./DecisionsPanel"; +import { ItemHistoryView } from "./ItemHistoryView"; type EpicFields = Pick & { justificativa?: string }; @@ -153,9 +154,9 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId const isDirty = editing && epic !== null && formValues !== null && JSON.stringify(formValues) !== JSON.stringify(toFields(epic)); const { confirmLeave } = useUnsavedChangesGuard(isDirty); - if (result.state === "loading") return
    Carregando épico…
    ; - if (result.state === "error") return

    {result.message}

    -
    ; + if (result.state === "loading") return
    Carregando épico…
    ; + if (result.state === "error") return

    {result.message}

    +
    ; if (!epic) return null; const epicoArquivado = epic.status === "arquivado"; @@ -185,10 +186,10 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId />

    Épico

    {epic.titulo}

    - +
    {readOnly && ( -
    +

    {epicoArquivado ? "Este épico está arquivado e está disponível apenas para leitura." @@ -196,8 +197,8 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId

    )} -
    - {epic.status} +
    + {epic.status} {editing && formValues ? ( <> {([ @@ -231,7 +232,7 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId )} {editMessage &&

    {editMessage}

    }
    - - + }}>{saving ? "Salvando…" : "Salvar alterações"} +
    ) : ( @@ -270,9 +271,9 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId {canWrite && (
    - + {(epic.status === "rascunho" || epic.status === "ativo") && ( - + }}>{completing ? "Concluindo…" : "Marcar como concluído"} )}
    )} diff --git a/frontend/src/views/backlog/FeaturesView.tsx b/frontend/src/views/backlog/FeaturesView.tsx index 5cce7dc..8b58d88 100644 --- a/frontend/src/views/backlog/FeaturesView.tsx +++ b/frontend/src/views/backlog/FeaturesView.tsx @@ -1,16 +1,17 @@ -import { ReadOnlyContext } from "../../models/ReadOnlyContext"; -import { ItemArchiveView } from "./ItemArchiveView"; import { useContext, useEffect, useRef, useState, type ReactNode } from "react"; import { ApiError } from "../../api/api_auth"; -import { navigate } from "../../models/navigation"; -import { createFeature, completeFeature, updateFeature, getFeature, listFeatures, camposFaltantesDe, type Feature, type FeatureInput } from "../../api/api_backlog"; +import { camposFaltantesDe, completeFeature, createFeature, getFeature, listFeatures, updateFeature, type Feature, type FeatureInput } from "../../api/api_backlog"; +import "../../assets/styles/projects.css"; import { descreverCamposFaltantes } from "../../models/fields"; -import { useUnsavedChangesGuard } from "../../viewmodels/useUnsavedChangesGuard"; +import { navigate } from "../../models/navigation"; +import { ReadOnlyContext } from "../../models/ReadOnlyContext"; import { usePbiQualityConfiguration } from "../../viewmodels/usePbiQualityConfiguration"; -import { CriteriaEditor } from "./CriteriaView"; +import { useUnsavedChangesGuard } from "../../viewmodels/useUnsavedChangesGuard"; +import { Button } from "../common/ui"; import { BacklogBreadcrumb } from "./BacklogBreadcrumb"; import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; -import "../../assets/styles/projects.css"; +import { CriteriaEditor } from "./CriteriaView"; +import { ItemArchiveView } from "./ItemArchiveView"; type ListResult = { state: "loading" } | { state: "error"; message: string } | { state: "ready"; features: Feature[] }; const emptyInput: FeatureInput = { epico_id: "", titulo: "", descricao: "", objetivo: "", tecnologias_ids: [] }; @@ -38,21 +39,21 @@ export function FeatureList({ projectId, epicoId, canCreate: allowedToCreate }:

    Features do épico

    Features

    - {canCreate && } + {canCreate && }
    - {result.state === "loading" &&
    Carregando features…
    } - {result.state === "error" &&

    {result.message}

    -
    } + {result.state === "loading" &&
    Carregando features…
    } + {result.state === "error" &&

    {result.message}

    +
    } {result.state === "ready" && (result.features.length === 0 - ?

    Nenhuma feature cadastrada

    Crie a primeira feature para detalhar este épico.

    - {canCreate && }
    + ?

    Nenhuma feature cadastrada

    Crie a primeira feature para detalhar este épico.

    + {canCreate && }
    :
    {result.features.map((feature) => ( -
    - {feature.status} +
    + {feature.status}

    {feature.titulo}

    {feature.objetivo || "Sem objetivo registrado."}

    - +
    ))}
    )}
    @@ -73,7 +74,7 @@ export function FeatureForm({ projectId, epicoId }: { projectId: string; epicoId

    Features / Nova feature

    Criar feature

    Apenas o título é obrigatório para salvar como rascunho.

    -
    { + { event.preventDefault(); if (submitting.current) return; if (!values.titulo.trim()) { setMessage("Informe o título da feature."); return; } @@ -104,16 +105,16 @@ export function FeatureForm({ projectId, epicoId }: { projectId: string; epicoId /> {message &&

    {message}

    }
    - - + +
    ); } -import { ItemHistoryView } from "./ItemHistoryView"; import { DecisionsPanel } from "./DecisionsPanel"; +import { ItemHistoryView } from "./ItemHistoryView"; type FeatureFields = Pick & { justificativa?: string }; @@ -150,9 +151,9 @@ export function FeatureDetail({ projectId, epicoId, featureId, canEdit, children const isDirty = editing && feature !== null && formValues !== null && JSON.stringify(formValues) !== JSON.stringify(toFields(feature)); const { confirmLeave } = useUnsavedChangesGuard(isDirty); - if (result.state === "loading") return
    Carregando feature…
    ; - if (result.state === "error") return

    {result.message}

    -
    ; + if (result.state === "loading") return
    Carregando feature…
    ; + if (result.state === "error") return

    {result.message}

    +
    ; const readOnly = feature!.status === "arquivado" || feature!.projeto_status === "arquivado"; const justificationRequired = feature!.status === "concluido" @@ -182,11 +183,11 @@ export function FeatureDetail({ projectId, epicoId, featureId, canEdit, children />

    Épico: {feature!.epico_titulo}

    {feature!.titulo}

    - +
    - {readOnly &&

    Esta feature pertence a um projeto arquivado e está disponível apenas para leitura.

    } -
    - {feature!.status} + {readOnly &&

    Esta feature pertence a um projeto arquivado e está disponível apenas para leitura.

    } +
    + {feature!.status} {editing && formValues ? ( <> {([["titulo", "Título", "input"], ["objetivo", "Objetivo", "textarea"], ["descricao", "Descrição", "textarea"]] as const).map(([field, label, kind]) => ( @@ -217,7 +218,7 @@ export function FeatureDetail({ projectId, epicoId, featureId, canEdit, children )} {editMessage &&

    {editMessage}

    }
    - - + }}>{saving ? "Salvando…" : "Salvar alterações"} +
    ) : ( @@ -254,9 +255,9 @@ export function FeatureDetail({ projectId, epicoId, featureId, canEdit, children {!readOnly && canEdit && (
    - + {feature!.status === "rascunho" && ( - + }}>{completing ? "Concluindo…" : "Marcar como concluída"} )}
    )} diff --git a/frontend/src/backlog/Hierarchy.test.tsx b/frontend/src/views/backlog/Hierarchy.test.tsx similarity index 98% rename from frontend/src/backlog/Hierarchy.test.tsx rename to frontend/src/views/backlog/Hierarchy.test.tsx index eb2cbb8..559725d 100644 --- a/frontend/src/backlog/Hierarchy.test.tsx +++ b/frontend/src/views/backlog/Hierarchy.test.tsx @@ -18,16 +18,16 @@ import { import { BacklogBreadcrumb, -} from "../views/backlog/BacklogBreadcrumb"; +} from "./BacklogBreadcrumb"; import { BacklogTreeView, filterBacklogTree, -} from "../views/backlog/BacklogTreeView"; +} from "./BacklogTreeView"; import type { ProjectBacklogTree, -} from "../api/api_backlog_tree"; +} from "../../api/api_backlog_tree"; const tree: ProjectBacklogTree = { project: { diff --git a/frontend/src/backlog/ItemArchive.test.tsx b/frontend/src/views/backlog/ItemArchive.test.tsx similarity index 98% rename from frontend/src/backlog/ItemArchive.test.tsx rename to frontend/src/views/backlog/ItemArchive.test.tsx index 943fa85..2130791 100644 --- a/frontend/src/backlog/ItemArchive.test.tsx +++ b/frontend/src/views/backlog/ItemArchive.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { ItemArchive } from "./ItemArchive"; +import { ItemArchiveView as ItemArchive } from "./ItemArchiveView"; const project = { id: "project-1", titulo: "Sinapse", cliente: "Cliente", descricao: "Contexto", status: "ativo" as const }; diff --git a/frontend/src/views/backlog/ItemArchiveView.tsx b/frontend/src/views/backlog/ItemArchiveView.tsx index 7148dc8..ec4314e 100644 --- a/frontend/src/views/backlog/ItemArchiveView.tsx +++ b/frontend/src/views/backlog/ItemArchiveView.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; -import { apiRequest, ApiError } from "../../api/api_auth"; +import { ApiError, apiRequest } from "../../api/api_auth"; +import { Button } from "../common/ui"; type ArchiveImpact = { epicos: number; features: number; pbis: number }; type Item = { id: string; titulo: string; status: string }; @@ -36,15 +37,15 @@ export function ItemArchiveView({ project, kind, canWrite, onArchived }: { proje } finally { pending.current = false; if (mounted.current) setBusy(false); } } return
    - + {error &&

    {error}

    } { if (busy) event.preventDefault(); else setImpact(null); }}>

    Arquivar {project.titulo}?

    {impact && <>

    Serão arquivados {impact.epicos} épico(s), {impact.features} feature(s) e {impact.pbis} PBI(s).

    Os registros serão preservados para consulta. Os itens já arquivados manterão suas datas.

    } {busy &&

    Arquivando…

    } -
    -
    +
    +
    ; } diff --git a/frontend/src/views/backlog/ItemHistoryView.tsx b/frontend/src/views/backlog/ItemHistoryView.tsx index 3e64f51..008bfe8 100644 --- a/frontend/src/views/backlog/ItemHistoryView.tsx +++ b/frontend/src/views/backlog/ItemHistoryView.tsx @@ -1,6 +1,7 @@ -import { useState, useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { getItemHistory, type AuditHistoryItem } from "../../api/api_backlog"; import { describeAuditChanges } from "../../models/auditChanges"; +import { Button } from "../common/ui"; export interface ItemHistoryViewProps { entidadeTipo: "epico" | "feature" | "pbi"; @@ -100,25 +101,25 @@ export function ItemHistoryView({ } if (loading) { - return
    Carregando histórico de auditoria…
    ; + return
    Carregando histórico de auditoria…
    ; } if (error) { return ( -
    +

    {error}

    - +
    ); } return ( -
    +
    Rastreabilidade e auditoria @@ -136,72 +137,72 @@ export function ItemHistoryView({

    Nenhum registro de alteração encontrado para este item.

    ) : (
      - {history.map((item) => { - const changes = describeAuditChanges({ - ...item.dados_json, - ...(item.pbi_snapshot ? { pbi_snapshot: item.pbi_snapshot } : {}), - }); - return ( -
    • -
      - {item.usuario_nome || "Usuário não identificado"} - - {new Date(item.created_at).toLocaleString("pt-BR")} - -
      -
      - {item.acao} - {item.pbi_versao !== null && item.pbi_versao !== undefined && ( - Versão {item.pbi_versao} - )} -
      - {changes.length > 0 && ( -
      - {changes.map((change) => ( -
      -
      {change.label}
      -
      - {change.before !== undefined - ? `${change.before} → ${change.after ?? "Não informado"}` - : change.after} -
      -
      - ))} -
      - )} - {item.justificativa ? ( -
      - Justificativa da alteração: {item.justificativa} + {history.map((item) => { + const changes = describeAuditChanges({ + ...item.dados_json, + ...(item.pbi_snapshot ? { pbi_snapshot: item.pbi_snapshot } : {}), + }); + return ( +
    • +
      + {item.usuario_nome || "Usuário não identificado"} + + {new Date(item.created_at).toLocaleString("pt-BR")} +
      - ) : ( -
      - Sem justificativa registrada (alteração em rascunho ou sem exigência). +
      + {item.acao} + {item.pbi_versao !== null && item.pbi_versao !== undefined && ( + Versão {item.pbi_versao} + )}
      - )} -
    • - ); - })} + {changes.length > 0 && ( +
      + {changes.map((change) => ( +
      +
      {change.label}
      +
      + {change.before !== undefined + ? `${change.before} → ${change.after ?? "Não informado"}` + : change.after} +
      +
      + ))} +
      + )} + {item.justificativa ? ( +
      + Justificativa da alteração: {item.justificativa} +
      + ) : ( +
      + Sem justificativa registrada (alteração em rascunho ou sem exigência). +
      + )} + + ); + })}
    )} {moreError &&

    {moreError}

    } {nextCursor && ( - + )}
    diff --git a/frontend/src/backlog/Justificativa.test.tsx b/frontend/src/views/backlog/Justificativa.test.tsx similarity index 97% rename from frontend/src/backlog/Justificativa.test.tsx rename to frontend/src/views/backlog/Justificativa.test.tsx index e6b0201..d66a893 100644 --- a/frontend/src/backlog/Justificativa.test.tsx +++ b/frontend/src/views/backlog/Justificativa.test.tsx @@ -1,10 +1,10 @@ // @vitest-environment jsdom import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { ItemHistoryView } from "../views/backlog/ItemHistoryView"; -import { EpicDetail } from "../views/backlog/EpicsView"; -import { FeatureDetail } from "../views/backlog/FeaturesView"; -import { CriteriaEditor } from "../views/backlog/CriteriaView"; +import { ItemHistoryView } from "./ItemHistoryView"; +import { EpicDetail } from "./EpicsView"; +import { FeatureDetail } from "./FeaturesView"; +import { CriteriaEditor } from "./CriteriaView"; const epicConcluido = { id: "epic-c1", diff --git a/frontend/src/views/backlog/PbiDetail.tsx b/frontend/src/views/backlog/PbiDetail.tsx new file mode 100644 index 0000000..40ed282 --- /dev/null +++ b/frontend/src/views/backlog/PbiDetail.tsx @@ -0,0 +1,784 @@ +import { useEffect, useState } from "react"; +import { ApiError } from "../../api/api_auth"; +import { + camposFaltantesDe, + completePbi, + getPbi, + hasCompletudeIndicator, + updatePbi, + type Criterion, + type Pbi, + type PbiInput +} from "../../api/api_backlog"; +import "../../assets/styles/projects.css"; +import { descreverCamposFaltantes } from "../../models/fields"; +import { navigate } from "../../models/navigation"; +import { evaluatePbiRealtime } from "../../models/qualityEngine"; +import { usePbiQualityConfiguration } from "../../viewmodels/usePbiQualityConfiguration"; +import { useUnsavedChangesGuard } from "../../viewmodels/useUnsavedChangesGuard"; +import { Button } from "../common/ui"; +import { BacklogBreadcrumb } from "./BacklogBreadcrumb"; +import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; +import { CriteriaEditor } from "./CriteriaView"; +import { DecisionsPanel } from "./DecisionsPanel"; +import { ItemHistoryView } from "./ItemHistoryView"; +import { QualityPanelView as QualityPanel } from "./QualityPanelView"; + +type PbiFields = Pick< + PbiInput, + | "titulo" + | "historia_como_um" + | "historia_eu_quero" + | "historia_para_que" + | "requer_interface" + | "tecnologias_ids" +> & { justificativa?: string }; + +function toFields( + pbi: Pbi, +): PbiFields { + return { + titulo: pbi.titulo, + historia_como_um: + pbi.historia_como_um, + historia_eu_quero: + pbi.historia_eu_quero, + historia_para_que: + pbi.historia_para_que, + requer_interface: + pbi.requer_interface, + tecnologias_ids: [...(pbi.tecnologias_ids ?? [])], + justificativa: "", + }; +} + +export function PbiDetail({ + projectId, + epicoId, + featureId, + pbiId, + canEdit, +}: { + projectId: string; + epicoId: string; + featureId: string; + pbiId: string; + canEdit: boolean; +}) { + const [result, setResult] = useState< + | { state: "loading" } + | { state: "error"; message: string } + | { state: "ready"; pbi: Pbi } + >({ + state: "loading", + }); + + const [completing, setCompleting] = + useState(false); + + const [ + completionMessage, + setCompletionMessage, + ] = useState(""); + + const [attempt, setAttempt] = useState(0); + const [editing, setEditing] = useState(false); + + const [formValues, setFormValues] = + useState(null); + + const [saving, setSaving] = useState(false); + + const [editMessage, setEditMessage] = + useState(""); + + const [scenarios, setScenarios] = + useState(null); + + const qualityConfiguration = + usePbiQualityConfiguration(); + + useEffect(() => { + setScenarios(null); + }, [pbiId]); + + useEffect(() => { + const controller = + new AbortController(); + + setResult({ + state: "loading", + }); + + getPbi( + pbiId, + controller.signal, + ) + .then((pbi) => { + if (!controller.signal.aborted) { + setResult({ + state: "ready", + pbi, + }); + } + }) + .catch((error) => { + if (controller.signal.aborted) { + return; + } + + setResult({ + state: "error", + message: + error instanceof ApiError + && error.status === 404 + ? "PBI não encontrado." + : "Não foi possível carregar o PBI.", + }); + }); + + return () => controller.abort(); + }, [pbiId, attempt]); + + const pbi = + result.state === "ready" + ? result.pbi + : null; + const justificationRequired = pbi?.status === "concluido" + && (qualityConfiguration.result.state !== "ready" + || qualityConfiguration.result.config.exigir_justificativa_item_concluido); + + const isDirty = + editing + && pbi !== null + && formValues !== null + && JSON.stringify(formValues) + !== JSON.stringify(toFields(pbi)); + + const { confirmLeave } = + useUnsavedChangesGuard(isDirty); + + const qualityValues = pbi + ? ( + editing && formValues + ? formValues + : toFields(pbi) + ) + : null; + + const qualityReport = + qualityValues + && scenarios + && qualityConfiguration.result.state + === "ready" + ? evaluatePbiRealtime( + { + ...qualityValues, + prototipo_vinculado: + pbi?.prototipo_vinculado, + }, + scenarios, + qualityConfiguration.result.config, + editing ? "edit-" : "", + ) + : null; + + if (result.state === "loading") { + return ( +
    + Carregando PBI… +
    + ); + } + + if (result.state === "error") { + return ( +
    +

    + {result.message} +

    + + +
    + ); + } + + const readOnly = + pbi!.projeto_status === "arquivado"; + + return ( +
    + { + if (confirmLeave()) navigate(path); + }} + /> +
    +
    +

    + Feature: {pbi!.feature_titulo} +

    + +

    + {pbi!.codigo} + {" — "} + {pbi!.titulo} +

    +
    + + +
    + + {readOnly && ( +
    +

    + Este PBI pertence a um projeto + arquivado e está disponível apenas + para leitura. +

    +
    + )} + + {( + qualityConfiguration.result.state + === "loading" + || ( + qualityConfiguration.result.state + === "ready" + && scenarios === null + ) + ) && ( +
    + Carregando o checklist de qualidade… +
    + )} + + {qualityConfiguration.result.state + === "error" && ( +
    +

    + Não foi possível carregar a configuração + de qualidade vigente. O checklist não + será exibido com regras presumidas. +

    + + +
    + )} + + {qualityReport && ( + + )} + +
    +
    + + {pbi!.status} + + + {hasCompletudeIndicator( + pbi!.score_completude, + ) && ( + = 80 + ? "ds-badge--success" + : pbi!.score_completude! >= 50 + ? "ds-badge--warning" + : "ds-badge--danger" + }` + } + > + {pbi!.score_completude}% + {" "} + completo + + )} +
    + + {editing && formValues + ? ( + <> +
    + + + + setFormValues( + (value) => + value && { + ...value, + titulo: + event.target.value, + }, + ) + } + /> +
    + +
    + + + + setFormValues( + (value) => + value && { + ...value, + historia_como_um: + event.target.value, + }, + ) + } + /> +
    + +
    + + + + setFormValues( + (value) => + value && { + ...value, + historia_eu_quero: + event.target.value, + }, + ) + } + /> +
    + +
    + + + + setFormValues( + (value) => + value && { + ...value, + historia_para_que: + event.target.value, + }, + ) + } + /> +
    + +
    + +
    + + setFormValues((current) => current && { ...current, tecnologias_ids })} + disabled={saving} + /> + + {justificationRequired && ( +
    + +