diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1000e71..b6df3a5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -18,7 +18,61 @@ "Bash(grep -v \"^$\")", "Bash(python3:*)", "Bash(grep -rn \"crédito\\\\|credit\\\\|0,00\\\\|R\\\\$\" /c/vscode/nalu/src/Nalu.Web/Pages --include=\"*.cshtml\")", - "Bash(git add:*)" + "Bash(git add:*)", + "Bash(git:*)", + "Bash(grep -v \".cs$\")", + "Bash(npm install:*)", + "Bash(xargs sed:*)", + "Bash(sed -i 's|href=\"mailto:contato@naluai\\\\.site\"|data-email|g' Painel/Index.cshtml)", + "Bash(sed -i 's|>contato@naluai\\\\.site|data-email-keep-text>|g' Painel/Index.cshtml)", + "Bash(sed -i 's|contato@naluai\\\\.site||g' Legal/Termos.cshtml)", + "Bash(sed -i 's|contato@naluai\\\\.site||g' En/Legal/Terms.cshtml)", + "Bash(sed -i 's|privacidade@naluai\\\\.site||g' Legal/Privacidade.cshtml)", + "Bash(ctx auto:*)", + "Bash(ls:*)", + "Bash(grep -v \"//.*model\")", + "Bash(dotnet sln add:*)", + "Bash(dotnet run:*)", + "Bash(curl -s \"https://servicodados.ibge.gov.br/api/v2/censos/nomes/%5B%5D?groupBy=sexo\")", + "Bash(curl -s \"https://servicodados.ibge.gov.br/api/v2/censos/nomes/ranking/?groupBy=sexo\")", + "Bash(curl -s \"https://servicodados.ibge.gov.br/api/v2/censos/nomes/MELECO\")", + "Bash(curl -s \"https://servicodados.ibge.gov.br/api/v2/censos/nomes/ranking/?sexo=M\")", + "Bash(curl -s \"https://servicodados.ibge.gov.br/api/v2/censos/nomes/ranking/?sexo=F\")", + "Bash(mongosh nalu:*)", + "Bash(mongosh \"mongodb://localhost:27017/nalu\" --quiet --eval \"db.nomes_br.find\\({},{_id:0,nome:1,genero:1,frequencia:1}\\).sort\\({frequencia:-1}\\).toArray\\(\\)\")", + "Bash(dotnet restore:*)", + "Bash(curl -s \"https://api.nuget.org/v3-flatcontainer/hangfire.mongo/index.json\")", + "Bash(curl -s \"https://api.nuget.org/v3/registration5/hangfire.mongo/1.15.0.json\")", + "Bash(curl -s \"https://api.nuget.org/v3-flatcontainer/hangfire.aspnetcore/index.json\")", + "Bash(powershell -Command '[System.Reflection.Assembly]::LoadFrom\\('\\\\''/c/Users/ricar/.nuget/packages/hangfire.core/1.8.22/lib/netstandard2.0/Hangfire.Core.dll'\\\\''\\).GetTypes\\(\\) | Where-Object { $_.Name -like '\\\\''*Dashboard*'\\\\'' } | Select-Object -ExpandProperty FullName')", + "Bash(powershell -Command '$dll = '\\\\''C:\\\\Users\\\\ricar\\\\.nuget\\\\packages\\\\hangfire.core\\\\1.8.22\\\\lib\\\\netstandard2.0\\\\Hangfire.Core.dll'\\\\''; [System.Reflection.Assembly]::LoadFrom\\($dll\\).GetTypes\\(\\) | Where-Object { $_.FullName -like '\\\\''*Dashboard*'\\\\'' } | Select-Object -ExpandProperty FullName')", + "Bash(powershell -Command ':*)", + "Bash(xargs '-I{}' powershell -noprofile -Command ' try { $a = [System.Reflection.Assembly]::LoadFile\\('\\\\''{}'\\\\''\\) $a.GetTypes\\(\\) | Where-Object { $_.Name -eq '\\\\''DashboardOptions'\\\\'' } | Select-Object FullName } catch {} ')", + "Bash(powershell -noprofile -Command ':*)", + "Bash(grep -E \"http|https|urls|port\" C:/vscode/nalu/src/Nalu.Web/appsettings*.json)", + "Bash(echo \"PID: $!\")", + "Bash(grep -r \"api.key\\\\|ApiKey\\\\|api_key\" C:/vscode/nalu/src/Nalu.Web/appsettings*.json)", + "Bash(curl -s -X POST http://localhost:5282/v1/extract/name -H 'Content-Type: application/json' -H 'X-Api-Key: nalu-test-key-001' -d '{\"agent_input\":\"Como posso te chamar?\",\"user_input\":\"João Silva\",\"agent_context\":\"Você é um agente de atendimento ao cliente.\"}')", + "Bash(curl -v -X POST http://localhost:5282/v1/extract/name -H 'Content-Type: application/json' -H 'X-Api-Key: nalu-test-key-001' -d '{\"agent_input\":\"Como posso te chamar?\",\"user_input\":\"Joao Silva\",\"agent_context\":\"atendimento\"}')", + "Bash(xargs cat:*)", + "Bash(curl:*)", + "Bash(taskkill /PID 33936 /F)", + "Bash(powershell:*)", + "Bash(mongosh mongodb://localhost:27017/naluai --quiet --eval 'db.nomes_br.find\\({nome: {'\\\\''$in'\\\\'': ['\\\\''JOAO'\\\\'','\\\\''CARLOS'\\\\'','\\\\''SILVA'\\\\'','\\\\''MELECO'\\\\'','\\\\''BRACOCURTO'\\\\'']}}, {nome:1, _id:0}\\).toArray\\(\\)')", + "Bash(mongosh --version)", + "Bash(mongo --version)", + "Read(//c/vscode/**)", + "Bash(find /c -maxdepth 3 -name \".gitea\" -type d)", + "Bash(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ubuntu@141.148.162.114 'docker stack ls; docker service ls; cat /etc/nginx/sites-available/bcards.site 2>/dev/null || ls /etc/nginx/sites-available/')", + "Bash(ssh:*)", + "Bash(rsync -az --delete --exclude=.git '--exclude=**/bin/' '--exclude=**/obj/' '--exclude=**/.vs/' --exclude=Data/ -e 'ssh -o StrictHostKeyChecking=no' /c/vscode/nalu/ ubuntu@141.148.162.114:/tmp/nalu-build/)", + "Bash(where rsync:*)", + "Bash(scp -o StrictHostKeyChecking=no /tmp/nalu-src.tar.gz ubuntu@141.148.162.114:/tmp/)", + "Bash(scp -o StrictHostKeyChecking=no /c/vscode/nalu/Dockerfile ubuntu@141.148.162.114:/tmp/nalu-build/Dockerfile)", + "Bash(scp -o StrictHostKeyChecking=no /c/vscode/nalu/docker-entrypoint.sh ubuntu@141.148.162.114:/tmp/nalu-build/docker-entrypoint.sh)", + "Bash(scp -o StrictHostKeyChecking=no /c/vscode/nalu/deploy/docker-stack-nalu.yml ubuntu@141.148.162.114:/tmp/nalu-build/deploy/)", + "Bash(tar -czf /tmp/nalu-jobs.tar.gz src/Nalu.Jobs/)", + "Bash(scp -o StrictHostKeyChecking=no /tmp/nalu-jobs.tar.gz ubuntu@141.148.162.114:/tmp/)" ] } } diff --git a/.gitea/workflows/deploy-nalu.yml b/.gitea/workflows/deploy-nalu.yml new file mode 100644 index 0000000..624aa03 --- /dev/null +++ b/.gitea/workflows/deploy-nalu.yml @@ -0,0 +1,274 @@ +name: NALU Deployment Pipeline + +# ─── Required Gitea Secrets ──────────────────────────────────────────────── +# secrets.SSH_PRIVATE_KEY – SSH key for ubuntu@ on OCI nodes +# secrets.NALU_MONGODB_CONNECTION – MongoDB connection string (with password) +# secrets.NALU_GROQ_API_KEY – Groq API key +# secrets.NALU_OPENROUTER_API_KEY – OpenRouter API key +# secrets.NALU_GOOGLEAI_API_KEY – Google AI (Gemini) API key +# secrets.NALU_STRIPE_SECRET_KEY – Stripe secret key +# secrets.NALU_STRIPE_WEBHOOK_SECRET – Stripe webhook signing secret +# secrets.NALU_OAUTH_GOOGLE_SECRET – Google OAuth client secret +# secrets.NALU_OAUTH_MS_SECRET – Microsoft OAuth client secret +# secrets.NALU_OAUTH_GITHUB_SECRET – GitHub OAuth client secret +# +# ─── Required Gitea Variables (vars.*) ──────────────────────────────────── +# vars.NALU_STRIPE_PUBLISHABLE_KEY +# vars.NALU_OAUTH_GOOGLE_CLIENT_ID +# vars.NALU_OAUTH_MS_CLIENT_ID +# vars.NALU_OAUTH_GITHUB_CLIENT_ID + +on: + push: + branches: + - main + pull_request: + branches: [ main ] + types: [opened, synchronize, reopened] + +env: + REGISTRY: registry.redecarneir.us + IMAGE_NAME: nalu + SWARM_MANAGER: 141.148.162.114 + SWARM_WORKER: 129.146.116.218 + +jobs: + # ─── Tests ──────────────────────────────────────────────────────────────── + test: + name: Run Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET 9 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Cache NuGet packages + uses: actions/cache@v3 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Restore dependencies + run: dotnet restore + + - name: Build solution + run: dotnet build --no-restore --configuration Release + + - name: Run tests + run: dotnet test --no-build --configuration Release --verbosity normal + + # ─── PR Validation ──────────────────────────────────────────────────────── + pr-validation: + name: PR Validation + runs-on: ubuntu-latest + needs: [test] + if: github.event_name == 'pull_request' + + steps: + - name: PR ready + run: echo "✅ PR validated — tests passed" + + # ─── Build & Push ───────────────────────────────────────────────────────── + build-and-push: + name: Build and Push Image + runs-on: ubuntu-latest + needs: [test] + if: github.event_name == 'push' && github.ref_name == 'main' && needs.test.result == 'success' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H ${{ env.SWARM_MANAGER }} >> ~/.ssh/known_hosts 2>/dev/null + + - name: Build image on ARM server + run: | + echo "🏗️ Syncing source to ARM builder..." + ssh -o StrictHostKeyChecking=no ubuntu@${{ env.SWARM_MANAGER }} 'rm -rf /tmp/nalu-build && mkdir -p /tmp/nalu-build' + rsync -az --delete \ + --exclude='.git' \ + --exclude='**/bin/' \ + --exclude='**/obj/' \ + --exclude='**/.vs/' \ + -e "ssh -o StrictHostKeyChecking=no" \ + ./ ubuntu@${{ env.SWARM_MANAGER }}:/tmp/nalu-build/ + + echo "🔨 Building Docker image natively on ARM64..." + ssh -o StrictHostKeyChecking=no ubuntu@${{ env.SWARM_MANAGER }} << 'EOF' + set -e + cd /tmp/nalu-build + docker build \ + --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \ + --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \ + --progress=plain \ + . + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + rm -rf /tmp/nalu-build + echo "✅ Image pushed" + EOF + + # ─── Deploy: naluai.dev ─────────────────────────────────────────────────── + deploy: + name: Deploy naluai.dev + runs-on: ubuntu-latest + needs: [build-and-push] + if: github.ref_name == 'main' + environment: production + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Generate non-sensitive appsettings + run: | + cat > appsettings.nalu.json << 'CONFIG_EOF' + { + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Hangfire": { + "NameImporterCron": "0 3 1 * *" + }, + "Groq": { + "BaseUrl": "https://api.groq.com/openai/v1", + "Model": "llama-3.3-70b-versatile", + "MaxTokens": 500, + "Temperature": 0.1 + }, + "OpenRouter": { + "BaseUrl": "https://openrouter.ai/api/v1", + "Model": "google/gemma-4-31b-it:free", + "MaxTokens": 500, + "Temperature": 0.1 + }, + "GoogleAi": { + "BaseUrl": "https://generativelanguage.googleapis.com/v1beta/openai/", + "Model": "gemini-2.0-flash" + }, + "Plans": { + "free": { "credits_per_month": 3000, "credits_per_day": 100, "price_brl": 0, "price_usd": 0 }, + "starter": { "credits_per_month": 15000, "credits_per_day": 0, "price_brl": 2900, "price_usd": 590 }, + "indie": { "credits_per_month": 50000, "credits_per_day": 0, "price_brl": 6900, "price_usd": 1390 }, + "pro": { "credits_per_month": 250000, "credits_per_day": 0, "price_brl": 19900, "price_usd": 3990 } + }, + "RateLimit": { + "PerIpPerMinute": 60, + "PerIpPerHour": 500 + }, + "Cache": { + "DefaultTtlMinutes": 60 + }, + "Stripe": { + "PublishableKey": "${{ vars.NALU_STRIPE_PUBLISHABLE_KEY }}" + }, + "OAuth": { + "Google": { + "ClientId": "${{ vars.NALU_OAUTH_GOOGLE_CLIENT_ID }}" + }, + "Microsoft": { + "ClientId": "${{ vars.NALU_OAUTH_MS_CLIENT_ID }}", + "TenantId": "common" + }, + "GitHub": { + "ClientId": "${{ vars.NALU_OAUTH_GITHUB_CLIENT_ID }}" + } + } + } + CONFIG_EOF + echo "✅ appsettings.nalu.json gerado" + + - name: Deploy nalu stack + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H ${{ env.SWARM_MANAGER }} >> ~/.ssh/known_hosts 2>/dev/null + + scp -o StrictHostKeyChecking=no appsettings.nalu.json ubuntu@${{ env.SWARM_MANAGER }}:/tmp/ + scp -o StrictHostKeyChecking=no deploy/docker-stack-nalu.yml ubuntu@${{ env.SWARM_MANAGER }}:/tmp/ + + ssh -o StrictHostKeyChecking=no ubuntu@${{ env.SWARM_MANAGER }} << SSHEOF + set -e + + # ── Create/update Docker secrets ───────────────────────────────── + update_secret() { + local name=\$1 + local value=\$2 + docker secret rm "\$name" 2>/dev/null || true + printf '%s' "\$value" | docker secret create "\$name" - + } + + update_secret nalu_mongodb_connection '${{ secrets.NALU_MONGODB_CONNECTION }}' + update_secret nalu_groq_api_key '${{ secrets.NALU_GROQ_API_KEY }}' + update_secret nalu_openrouter_api_key '${{ secrets.NALU_OPENROUTER_API_KEY }}' + update_secret nalu_googleai_api_key '${{ secrets.NALU_GOOGLEAI_API_KEY }}' + update_secret nalu_stripe_secret_key '${{ secrets.NALU_STRIPE_SECRET_KEY }}' + update_secret nalu_stripe_webhook_secret '${{ secrets.NALU_STRIPE_WEBHOOK_SECRET }}' + update_secret nalu_oauth_google_secret '${{ secrets.NALU_OAUTH_GOOGLE_SECRET }}' + update_secret nalu_oauth_ms_secret '${{ secrets.NALU_OAUTH_MS_SECRET }}' + update_secret nalu_oauth_github_secret '${{ secrets.NALU_OAUTH_GITHUB_SECRET }}' + + # ── Create docker config ────────────────────────────────────────── + docker config rm nalu-appsettings 2>/dev/null || true + CONFIG_NAME="nalu-appsettings-\$(date +%s)" + docker config create "\${CONFIG_NAME}" /tmp/appsettings.nalu.json + + sed "s/nalu-appsettings/\${CONFIG_NAME}/g" /tmp/docker-stack-nalu.yml > /tmp/docker-stack-nalu-final.yml + + # ── Deploy stack ────────────────────────────────────────────────── + docker stack deploy -c /tmp/docker-stack-nalu-final.yml nalu --with-registry-auth + + rm -f /tmp/appsettings.nalu.json /tmp/docker-stack-nalu.yml /tmp/docker-stack-nalu-final.yml + echo "✅ nalu stack atualizado!" + SSHEOF + + - name: Health check + run: | + sleep 35 + ssh -o StrictHostKeyChecking=no ubuntu@${{ env.SWARM_MANAGER }} \ + 'curl -sf http://localhost:8084/health && echo "✅ nalu healthy" || (echo "❌ health check failed"; docker service ps nalu_app; exit 1)' + + # ─── Cleanup ────────────────────────────────────────────────────────────── + cleanup: + name: Cleanup Old Resources + runs-on: ubuntu-latest + needs: [deploy] + if: always() && needs.deploy.result == 'success' + + steps: + - name: Cleanup + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H ${{ env.SWARM_MANAGER }} >> ~/.ssh/known_hosts 2>/dev/null + ssh-keyscan -H ${{ env.SWARM_WORKER }} >> ~/.ssh/known_hosts 2>/dev/null + + for SERVER in ${{ env.SWARM_MANAGER }} ${{ env.SWARM_WORKER }}; do + ssh -o StrictHostKeyChecking=no ubuntu@$SERVER << 'EOF' + docker container prune -f + docker image prune -f + # Keep last 3 nalu configs + docker config ls --filter "name=nalu-appsettings" --format "{{.ID}} {{.Name}}" \ + | sort -k2 | head -n -3 | awk '{print $1}' \ + | xargs -r docker config rm 2>/dev/null || true + EOF + done + echo "✅ Cleanup done" diff --git a/CLAUDE.md b/CLAUDE.md index 922513f..7790784 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,3 +38,14 @@ ctx git # branch, status, recent commits, diff summary - `ctx` output is a **summary**, not the full picture. If you need implementation details (method bodies, exact logic, specific lines), read the file directly. - Do not run `ctx` commands that don't match the project stack. Use `ctx auto detect` if unsure. - `ctx csharp errors` assumes `dotnet restore` was already run. If you get restore errors, run `dotnet restore` first, then `ctx csharp errors`. + +## Multilingual + +Site supports PT (default) and EN (`/en`). ES (`/es`) is planned next. + +Before adding or modifying any language-related feature, read: **`docs/adding-language.md`** + +Key files: +- `Pages/_Layout.cshtml` — language detection, nav links, globe dropdown, hreflang/canonical tags +- `Pages/En/` — all English pages +- Language is URL-prefix based. No cookies. `Context.Request.Path` is the source of truth. diff --git a/Dockerfile b/Dockerfile index 0f3652a..9f0f937 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,23 +4,25 @@ EXPOSE 8080 FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build WORKDIR /src -COPY ["src/Nalu.Api/Nalu.Api.csproj", "src/Nalu.Api/"] -RUN dotnet restore "src/Nalu.Api/Nalu.Api.csproj" -COPY . . -WORKDIR "/src/src/Nalu.Api" -RUN dotnet build "Nalu.Api.csproj" -c Release -o /app/build -FROM build AS publish -# linux-arm64 targets OCI ARM64 servers; remove -r flag for x64 hosts -RUN dotnet publish "Nalu.Api.csproj" -c Release -o /app/publish \ +COPY ["src/Nalu.Web/Nalu.Web.csproj", "src/Nalu.Web/"] +COPY ["src/Nalu.Jobs/Nalu.Jobs.csproj", "src/Nalu.Jobs/"] +RUN dotnet restore "src/Nalu.Web/Nalu.Web.csproj" + +COPY . . +WORKDIR "/src/src/Nalu.Web" +RUN dotnet publish "Nalu.Web.csproj" -c Release -o /app/publish \ --self-contained false \ /p:UseAppHost=false FROM base AS final +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY --from=publish /app/publish . +COPY --from=build /app/publish . +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh ENV ASPNETCORE_URLS=http://+:8080 ENV ASPNETCORE_ENVIRONMENT=Production -ENTRYPOINT ["dotnet", "Nalu.Api.dll"] +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/Nalu.sln b/Nalu.sln index 73c980e..aaa3371 100644 --- a/Nalu.sln +++ b/Nalu.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 @@ -7,19 +7,76 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nalu.Web", "src\Nalu.Web\Na EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nalu.Tests", "tests\Nalu.Tests\Nalu.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F01234567891}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nalu.NameImporter", "src\Nalu.NameImporter\Nalu.NameImporter.csproj", "{BFB9B2E8-187D-45D9-A0B5-0D5BDFF1B5FA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nalu.Jobs", "src\Nalu.Jobs\Nalu.Jobs.csproj", "{6C8493DA-A479-4EF0-83A6-11BB251A74EB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F01234567891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F01234567891}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F01234567891}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F01234567891}.Debug|x64.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F01234567891}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F01234567891}.Debug|x86.Build.0 = Debug|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F01234567891}.Release|Any CPU.ActiveCfg = Release|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F01234567891}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F01234567891}.Release|x64.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F01234567891}.Release|x64.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F01234567891}.Release|x86.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F01234567891}.Release|x86.Build.0 = Release|Any CPU + {BFB9B2E8-187D-45D9-A0B5-0D5BDFF1B5FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BFB9B2E8-187D-45D9-A0B5-0D5BDFF1B5FA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BFB9B2E8-187D-45D9-A0B5-0D5BDFF1B5FA}.Debug|x64.ActiveCfg = Debug|Any CPU + {BFB9B2E8-187D-45D9-A0B5-0D5BDFF1B5FA}.Debug|x64.Build.0 = Debug|Any CPU + {BFB9B2E8-187D-45D9-A0B5-0D5BDFF1B5FA}.Debug|x86.ActiveCfg = Debug|Any CPU + {BFB9B2E8-187D-45D9-A0B5-0D5BDFF1B5FA}.Debug|x86.Build.0 = Debug|Any CPU + {BFB9B2E8-187D-45D9-A0B5-0D5BDFF1B5FA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BFB9B2E8-187D-45D9-A0B5-0D5BDFF1B5FA}.Release|Any CPU.Build.0 = Release|Any CPU + {BFB9B2E8-187D-45D9-A0B5-0D5BDFF1B5FA}.Release|x64.ActiveCfg = Release|Any CPU + {BFB9B2E8-187D-45D9-A0B5-0D5BDFF1B5FA}.Release|x64.Build.0 = Release|Any CPU + {BFB9B2E8-187D-45D9-A0B5-0D5BDFF1B5FA}.Release|x86.ActiveCfg = Release|Any CPU + {BFB9B2E8-187D-45D9-A0B5-0D5BDFF1B5FA}.Release|x86.Build.0 = Release|Any CPU + {6C8493DA-A479-4EF0-83A6-11BB251A74EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C8493DA-A479-4EF0-83A6-11BB251A74EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C8493DA-A479-4EF0-83A6-11BB251A74EB}.Debug|x64.ActiveCfg = Debug|Any CPU + {6C8493DA-A479-4EF0-83A6-11BB251A74EB}.Debug|x64.Build.0 = Debug|Any CPU + {6C8493DA-A479-4EF0-83A6-11BB251A74EB}.Debug|x86.ActiveCfg = Debug|Any CPU + {6C8493DA-A479-4EF0-83A6-11BB251A74EB}.Debug|x86.Build.0 = Debug|Any CPU + {6C8493DA-A479-4EF0-83A6-11BB251A74EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C8493DA-A479-4EF0-83A6-11BB251A74EB}.Release|Any CPU.Build.0 = Release|Any CPU + {6C8493DA-A479-4EF0-83A6-11BB251A74EB}.Release|x64.ActiveCfg = Release|Any CPU + {6C8493DA-A479-4EF0-83A6-11BB251A74EB}.Release|x64.Build.0 = Release|Any CPU + {6C8493DA-A479-4EF0-83A6-11BB251A74EB}.Release|x86.ActiveCfg = Release|Any CPU + {6C8493DA-A479-4EF0-83A6-11BB251A74EB}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {BFB9B2E8-187D-45D9-A0B5-0D5BDFF1B5FA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {6C8493DA-A479-4EF0-83A6-11BB251A74EB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/deploy/docker-stack-nalu.yml b/deploy/docker-stack-nalu.yml new file mode 100644 index 0000000..b071a84 --- /dev/null +++ b/deploy/docker-stack-nalu.yml @@ -0,0 +1,77 @@ +version: '3.8' + +configs: + nalu-appsettings: + external: true + +secrets: + nalu_mongodb_connection: + external: true + nalu_groq_api_key: + external: true + nalu_openrouter_api_key: + external: true + nalu_googleai_api_key: + external: true + nalu_stripe_secret_key: + external: true + nalu_stripe_webhook_secret: + external: true + nalu_oauth_google_secret: + external: true + nalu_oauth_ms_secret: + external: true + nalu_oauth_github_secret: + external: true + +services: + app: + image: registry.redecarneir.us/nalu:latest + networks: + - nalu-net + deploy: + replicas: 2 + placement: + max_replicas_per_node: 1 + update_config: + parallelism: 1 + order: stop-first + delay: 10s + monitor: 60s + failure_action: rollback + rollback_config: + parallelism: 0 + delay: 5s + configs: + - source: nalu-appsettings + target: /app/appsettings.Production.json + mode: 0444 + secrets: + - nalu_mongodb_connection + - nalu_groq_api_key + - nalu_openrouter_api_key + - nalu_googleai_api_key + - nalu_stripe_secret_key + - nalu_stripe_webhook_secret + - nalu_oauth_google_secret + - nalu_oauth_ms_secret + - nalu_oauth_github_secret + environment: + ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_URLS: http://+:8080 + ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + ports: + - published: 8084 + target: 8080 + protocol: tcp + mode: ingress + +networks: + nalu-net: + external: true diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..7e1b82f --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +read_secret() { + local file="/run/secrets/$1" + [ -f "$file" ] && cat "$file" +} + +# Inject Docker secrets as ASP.NET Core environment variables +val=$(read_secret nalu_mongodb_connection); [ -n "$val" ] && export ConnectionStrings__MongoDB="$val" +val=$(read_secret nalu_groq_api_key); [ -n "$val" ] && export Groq__ApiKey="$val" +val=$(read_secret nalu_openrouter_api_key); [ -n "$val" ] && export OpenRouter__ApiKey="$val" +val=$(read_secret nalu_googleai_api_key); [ -n "$val" ] && export GoogleAi__ApiKey="$val" +val=$(read_secret nalu_stripe_secret_key); [ -n "$val" ] && export Stripe__SecretKey="$val" +val=$(read_secret nalu_stripe_webhook_secret); [ -n "$val" ] && export Stripe__WebhookSecret="$val" +val=$(read_secret nalu_oauth_google_secret); [ -n "$val" ] && export OAuth__Google__ClientSecret="$val" +val=$(read_secret nalu_oauth_ms_secret); [ -n "$val" ] && export OAuth__Microsoft__ClientSecret="$val" +val=$(read_secret nalu_oauth_github_secret); [ -n "$val" ] && export OAuth__GitHub__ClientSecret="$val" + +exec dotnet Nalu.Web.dll diff --git a/docs/adding-language.md b/docs/adding-language.md new file mode 100644 index 0000000..c5ea5ed --- /dev/null +++ b/docs/adding-language.md @@ -0,0 +1,186 @@ +# Adding a new language + +Reference: implemented PT (default) + EN (`/en`). Next planned: ES (`/es`). + +## Architecture overview + +- Language is URL-prefix based: `/en/...`, `/es/...` +- No cookies, no sessions — URL is the source of truth +- Language detected in `_Layout.cshtml` via `Context.Request.Path` +- Pages without a translation simply don't get hreflang tags + +## Checklist to add a new language (e.g. `/es`) + +### 1. Create page files + +Mirror the `Pages/En/` folder structure: + +``` +Pages/Es/ + Index.cshtml + Index.cshtml.cs → @page "/es" + Pricing.cshtml + Pricing.cshtml.cs → @page "/es/pricing" + Playground.cshtml + Playground.cshtml.cs → @page "/es/playground" + Validators/ + Index.cshtml + Index.cshtml.cs → @page "/es/validators" + Docs/ + Index.cshtml + Index.cshtml.cs → @page "/es/docs" + Quickstart.cshtml + .cs → @page "/es/docs/quickstart" + ApiReference.cshtml + .cs → @page "/es/docs/api-reference" + Mcp.cshtml + .cs → @page "/es/docs/mcp" + N8n.cshtml + .cs → @page "/es/docs/n8n" + Credits.cshtml + .cs → @page "/es/docs/credits" + Errors.cshtml + .cs → @page "/es/docs/errors" +``` + +**Validator ordering for ES:** Universal first (same as EN), Brazilian second. + +### 2. Update `_Layout.cshtml` + +File: `src/Nalu.Web/Pages/_Layout.cshtml` + +#### a) Language detection block (top `@{ }`) + +Add ES detection alongside EN: + +```csharp +var _isEn = Context.Request.Path.StartsWithSegments("/en"); +var _isEs = Context.Request.Path.StartsWithSegments("/es"); // ADD +var _lp = _isEn ? "/en" : _isEs ? "/es" : ""; // UPDATE +``` + +#### b) Known pages sets — add ES set + +```csharp +var _knownEs = new HashSet { + "/es", "/es/pricing", "/es/validators", "/es/playground", + "/es/docs", "/es/docs/quickstart", "/es/docs/api-reference", + "/es/docs/mcp", "/es/docs/n8n", "/es/docs/credits", "/es/docs/errors" +}; +bool _hasAlternate = (_isEn ? _knownEn : _isEs ? _knownEs : _knownPt).Contains(_cur); +``` + +#### c) `_altUrl` switch — add ES cases to all three branches + +The switch currently has EN→PT and PT→EN. Add: +- EN→ES branch (or link via PT as pivot) +- ES→PT branch +- PT→ES branch + +Simplest: each language maps to PT as canonical pivot, then PT maps to all others. Or: extend the switch to map directly between all pairs. Given 3 languages, direct mapping is cleaner: + +```csharp +// When on ES page, altUrl = PT equivalent +"/es" => "/", +"/es/pricing" => "/precos", +// ... etc + +// When on PT page, need to decide which alternate to link to. +// Convention: globe shows the "other" language not currently active. +// With 3 languages, globe becomes a dropdown with all options. +``` + +#### d) Globe dropdown — extend to 3 options + +Replace the current 2-option globe with a 3-option dropdown: + +```html +🇧🇷 Português @(_isEn || _isEs ? "" : "✓") +🇺🇸 English @(_isEn ? "✓" : "") +🇪🇸 Español @(_isEs ? "✓" : "") +``` + +You'll need to compute both `_altUrlEn` and `_altUrlEs` (and `_altUrlPt`) separately instead of a single `_altUrl`. + +#### e) Nav link labels — add ES strings + +```csharp +// Current: @(_isEn ? "Validators" : "Validadores") +// Updated: +@(_isEn ? "Validators" : _isEs ? "Validadores" : "Validadores") +// Most validator names are the same in ES/PT — adjust as needed. +``` + +Pricing label: +```csharp +@(_isEn ? "Pricing" : _isEs ? "Precios" : "Preços") +``` + +#### f) Footer — add ES to use cases section + +Currently the "Casos/Use cases" section links to PT-only `/casos/...` pages. For ES, either link to the same PT pages or create ES case study pages. + +#### g) hreflang — add ES alternate + +```csharp +// _esAbsolute = ES version URL + +``` + +#### h) `html lang` attribute + +```csharp + +``` + +### 3. Logo link + +Currently: +```csharp + +``` + +Update: +```csharp + +``` + +### 4. Pricing page — currency + +ES pricing: same USD values as EN page (`/en/pricing` as reference). Spanish-speaking markets outside Brazil → use USD, show BRL as `≈ R$ X` secondary (same pattern as EN). + +Or: if targeting Spain specifically, consider EUR. Decision needed per market. + +### 5. Validators ordering + +| Language | Order | +|----------|-------| +| PT | Brazilian first, Universal second | +| EN | Universal first, Brazilian second | +| ES | Universal first, Brazilian second (same as EN) | + +### 6. PT pages without EN equivalent + +Pages like `/casos/parcelas-48x`, `/docs/fluxos`, `/validadores/reply` have no EN/ES version. Globe dropdown on these pages shows links to EN/ES home (`/en`, `/es`) as fallback. This is intentional. + +## URL slug conventions + +| Concept | PT | EN | ES (planned) | +|-------------|--------------|----------------|--------------| +| Home | `/` | `/en` | `/es` | +| Pricing | `/precos` | `/en/pricing` | `/es/precios`| +| Validators | `/validadores`| `/en/validators`| `/es/validadores`| +| Playground | `/playground`| `/en/playground`| `/es/playground`| +| Docs | `/docs` | `/en/docs` | `/es/docs` | +| Credits | `/docs/creditos`| `/en/docs/credits`| `/es/docs/credits`| +| Errors | `/docs/erros`| `/en/docs/errors`| `/es/docs/errors`| + +## SEO tags (auto-generated in layout) + +The layout generates these automatically for pages with known translations: + +```html + + + + +``` + +`x-default` always points to PT (primary market). Add `hreflang="es"` when ES is implemented. + +## Files to touch (summary) + +When adding ES: + +1. `Pages/En/*.cshtml` — duplicate folder as `Pages/Es/`, translate content +2. `Pages/_Layout.cshtml` — 6 locations: detection, known sets, altUrl switch, globe dropdown, nav labels, hreflang tags +3. No changes needed to: PageModels (thin, language-agnostic), API endpoints, auth, Stripe diff --git a/src/Nalu.Api/Endpoints/ExtractEndpoints.cs b/src/Nalu.Api/Endpoints/ExtractEndpoints.cs index 893acaf..3b57f20 100644 --- a/src/Nalu.Api/Endpoints/ExtractEndpoints.cs +++ b/src/Nalu.Api/Endpoints/ExtractEndpoints.cs @@ -118,15 +118,29 @@ public static class ExtractEndpoints group.MapPost("/name", async (HttpContext ctx, [FromBody] ExtractionRequest req, ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_name", ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_name", req, ct)); + }) + .WithName("ExtractName") + .WithSummary("Extrai nome ou apelido") + .WithDescription("Extrai o nome ou apelido do usuário. Aceita primeiro nome sem sobrenome. Custa 2 créditos.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/full-name", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => { var cr = await credits.TryConsumeAsync(ctx.User, "validate_full_name", ct); credits.ApplyHeaders(ctx, cr); if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); return Results.Ok(await pipeline.ExecuteAsync("validate_full_name", req, ct)); }) - .WithName("ExtractName") - .WithSummary("Extrai nome completo") - .WithDescription("Detecta e valida o nome completo do usuário a partir do diálogo. Custa 2 créditos.") + .WithName("ExtractFullName") + .WithSummary("Extrai nome completo com sobrenome") + .WithDescription("Extrai o nome completo do usuário. Exige sobrenome — retorna certain: false se apenas o primeiro nome for informado. Custa 2 créditos.") .Produces().ProducesProblem(429).WithOpenApi(); group.MapPost("/yes-no", async (HttpContext ctx, diff --git a/src/Nalu.Api/Pages/Casos/Parcelas48x.cshtml b/src/Nalu.Api/Pages/Casos/Parcelas48x.cshtml index 5d28133..d1502a3 100644 --- a/src/Nalu.Api/Pages/Casos/Parcelas48x.cshtml +++ b/src/Nalu.Api/Pages/Casos/Parcelas48x.cshtml @@ -131,7 +131,7 @@

Código de integração

cURL
-
curl https://api.naluai.com/v1/extract/reply \
+            
curl https://api.naluai.dev/v1/extract/reply \
   -H "Authorization: Bearer SEU_TOKEN" \
   -H "Content-Type: application/json" \
   -d '{
@@ -142,7 +142,7 @@
 
             
JavaScript (n8n / Make)
const { reply_type, extracted_value, value_type } =
-  await $http.post('https://api.naluai.com/v1/extract/reply', {
+  await $http.post('https://api.naluai.dev/v1/extract/reply', {
     agent_message: $node['Agent'].json.message,
     user_reply: $node['User'].json.reply,
     language: 'pt-BR'
diff --git a/src/Nalu.Api/Pages/Index.cshtml b/src/Nalu.Api/Pages/Index.cshtml
index c6858da..5187e69 100644
--- a/src/Nalu.Api/Pages/Index.cshtml
+++ b/src/Nalu.Api/Pages/Index.cshtml
@@ -221,7 +221,7 @@ Mais barato que perder a venda.
-
curl https://api.naluai.com/v1/extract/name \
+
curl https://api.naluai.dev/v1/extract/name \
   -H "Authorization: Bearer SEU_TOKEN" \
   -H "Content-Type: application/json" \
   -d '{
@@ -241,7 +241,7 @@ Mais barato que perder a venda.
diff --git a/src/Nalu.Api/Pages/Painel/Index.cshtml b/src/Nalu.Api/Pages/Painel/Index.cshtml index da4898f..a8048b6 100644 --- a/src/Nalu.Api/Pages/Painel/Index.cshtml +++ b/src/Nalu.Api/Pages/Painel/Index.cshtml @@ -140,7 +140,7 @@
Como usar
-
curl https://api.naluai.com/v1/extract/cpf \
+            
curl https://api.naluai.dev/v1/extract/cpf \
   -H "Authorization: Bearer @(Model.Keys.FirstOrDefault()?.Key ?? "SUA_API_KEY")" \
   -H "Content-Type: application/json" \
   -d '{"agent_input":"Qual seu CPF?","user_input":"123.456.789-09"}'
diff --git a/src/Nalu.Api/PostProcessors/ValidateCnpjDigit.cs b/src/Nalu.Api/PostProcessors/ValidateCnpjDigit.cs index ca1c473..27325ef 100644 --- a/src/Nalu.Api/PostProcessors/ValidateCnpjDigit.cs +++ b/src/Nalu.Api/PostProcessors/ValidateCnpjDigit.cs @@ -2,8 +2,10 @@ using System.Text.RegularExpressions; namespace Nalu.Web.PostProcessors; -/// Validates CNPJ check digits (mod 11 algorithm), rejects repeated-digit sequences, -/// and formats as XX.XXX.XXX/XXXX-XX. +/// Validates CNPJ check digits (mod 11 algorithm) for both numeric and alphanumeric CNPJs +/// (IN RFB 2229/2024). Digits use face value (0–9); letters use raw ASCII (A=65…Z=90). +/// Last two characters must always be numeric check digits. +/// Formats as XX.XXX.XXX/XXXX-XX. public class ValidateCnpjDigit : IPostProcessor { public string Name => "validate_cnpj_digit"; @@ -13,37 +15,51 @@ public class ValidateCnpjDigit : IPostProcessor if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Invalid("CNPJ não informado"); - var digits = Regex.Replace(value, @"\D", ""); + // Uppercase and strip separators only (keep alphanumeric) + var normalized = Regex.Replace(value.ToUpperInvariant(), @"[.\-/\s]", ""); - if (digits.Length != 14) - return ProcessorResult.Invalid($"CNPJ deve ter 14 dígitos (encontrado: {digits.Length})"); + if (normalized.Length != 14) + return ProcessorResult.Invalid($"CNPJ deve ter 14 caracteres (encontrado: {normalized.Length})"); - // Reject all-same-digit sequences (00000000000000 … 99999999999999) - if (digits.Distinct().Count() == 1) - return ProcessorResult.Invalid("CNPJ inválido (sequência de dígitos repetidos)"); + // Validate character set: only digits and uppercase A-Z + if (!normalized.All(c => char.IsDigit(c) || (c >= 'A' && c <= 'Z'))) + return ProcessorResult.Invalid("CNPJ contém caracteres inválidos"); - if (!CheckDigits(digits)) + // Last two characters must be digits (check digits are always numeric) + if (!normalized[12..].All(char.IsDigit)) + return ProcessorResult.Invalid("Os dois últimos caracteres do CNPJ devem ser dígitos"); + + // Reject all-same-character sequences (000…0, AAA…A, etc.) + if (normalized.Distinct().Count() == 1) + return ProcessorResult.Invalid("CNPJ inválido (sequência de caracteres repetidos)"); + + if (!CheckDigits(normalized)) return ProcessorResult.Invalid("CNPJ inválido (dígitos verificadores incorretos)"); // Format XX.XXX.XXX/XXXX-XX - var formatted = $"{digits[..2]}.{digits[2..5]}.{digits[5..8]}/{digits[8..12]}-{digits[12..]}"; + var formatted = $"{normalized[..2]}.{normalized[2..5]}.{normalized[5..8]}/{normalized[8..12]}-{normalized[12..]}"; return ProcessorResult.Ok(formatted); } - private static bool CheckDigits(string d) + /// Maps a character to its numeric value for the mod 11 calculation. + /// Digits: face value (0–9). Letters: raw ASCII value (A=65 … Z=90). + /// Per Receita Federal CNPJ alphanumeric spec (COCAD). + private static int CharValue(char c) => char.IsDigit(c) ? c - '0' : (int)c; + + private static bool CheckDigits(string s) { - // First check digit (position 12) + // First check digit (position 12, 0-indexed) int[] w1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; - var sum1 = w1.Select((w, i) => (d[i] - '0') * w).Sum(); + var sum1 = w1.Select((w, i) => CharValue(s[i]) * w).Sum(); var r1 = sum1 % 11; var cd1 = r1 < 2 ? 0 : 11 - r1; - if (d[12] - '0' != cd1) return false; + if (s[12] - '0' != cd1) return false; - // Second check digit (position 13) + // Second check digit (position 13, 0-indexed) int[] w2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; - var sum2 = w2.Select((w, i) => (d[i] - '0') * w).Sum(); + var sum2 = w2.Select((w, i) => CharValue(s[i]) * w).Sum(); var r2 = sum2 % 11; var cd2 = r2 < 2 ? 0 : 11 - r2; - return d[13] - '0' == cd2; + return s[13] - '0' == cd2; } } diff --git a/src/Nalu.Api/Program.cs b/src/Nalu.Api/Program.cs index 56fa502..3f2db39 100644 --- a/src/Nalu.Api/Program.cs +++ b/src/Nalu.Api/Program.cs @@ -150,7 +150,7 @@ builder.Services.AddHttpClient(client => var baseUrl = builder.Configuration["OpenRouter:BaseUrl"] ?? "https://openrouter.ai/api/v1"; client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + '/'); client.DefaultRequestHeaders.Add("Authorization", $"Bearer {builder.Configuration["OpenRouter:ApiKey"]}"); - client.DefaultRequestHeaders.Add("HTTP-Referer", "https://naluai.com"); + client.DefaultRequestHeaders.Add("HTTP-Referer", "https://naluai.dev"); client.DefaultRequestHeaders.Add("X-Title", "NALU AI"); client.Timeout = TimeSpan.FromSeconds(30); }); diff --git a/src/Nalu.Api/Services/CreditCosts.cs b/src/Nalu.Api/Services/CreditCosts.cs index a7328d5..74b066a 100644 --- a/src/Nalu.Api/Services/CreditCosts.cs +++ b/src/Nalu.Api/Services/CreditCosts.cs @@ -17,6 +17,7 @@ public static class CreditCosts ["validate_postal_code"]= 1, // 2 credits — light LLM + ["validate_name"] = 2, ["validate_full_name"] = 2, ["validate_yes_no"] = 2, ["validate_birthdate"] = 2, @@ -34,7 +35,8 @@ public static class CreditCosts // Endpoint aliases → validator IDs private static readonly Dictionary _aliases = new(StringComparer.OrdinalIgnoreCase) { - ["name"] = "validate_full_name", + ["name"] = "validate_name", + ["full-name"] = "validate_full_name", ["cpf"] = "validate_cpf", ["cep"] = "validate_cep", ["phone"] = "validate_phone_br", diff --git a/src/Nalu.Api/Services/CreditService.cs b/src/Nalu.Api/Services/CreditService.cs index 7500896..80ea253 100644 --- a/src/Nalu.Api/Services/CreditService.cs +++ b/src/Nalu.Api/Services/CreditService.cs @@ -117,7 +117,7 @@ public class CreditService(MongoDbContext db, IConfiguration config) credits_used = used, credits_limit = limit, reset_at = resetAt.ToString("O"), - upgrade_url = "https://naluai.com/precos", + upgrade_url = "https://naluai.dev/precos", hint = "Upgrade para Starter por apenas R$ 0,0019 por validação. Menos que uma gota de café." }; diff --git a/src/Nalu.Api/Services/DeterministicLayer.cs b/src/Nalu.Api/Services/DeterministicLayer.cs index 7d4cdc6..ae5b9e2 100644 --- a/src/Nalu.Api/Services/DeterministicLayer.cs +++ b/src/Nalu.Api/Services/DeterministicLayer.cs @@ -55,17 +55,21 @@ public class DeterministicLayer catch (RegexMatchTimeoutException) { /* skip on timeout */ } } - // Accept patterns — capture group 1 is the extracted value + // Accept patterns — capture group 1 is the extracted value. + // Matched against the ORIGINAL (trimmed) input without global IgnoreCase, + // so patterns can be case-sensitive. Use (?i) inline for case-insensitive patterns. + var original = userInput.Trim().TrimEnd('.', '!', '?', ',', ';'); + foreach (var pattern in validator.AcceptPatterns) { try { - var m = Regex.Match(normalized, pattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100)); + var m = Regex.Match(original, pattern, RegexOptions.None, TimeSpan.FromMilliseconds(100)); if (!m.Success) continue; var extracted = m.Groups.Count > 1 && m.Groups[1].Success ? m.Groups[1].Value.Trim() - : userInput.Trim(); + : original; var violation = CheckConstraints(validator.Constraints, extracted); if (violation is not null) @@ -120,6 +124,14 @@ public class DeterministicLayer return "Valor deve conter letras"; } + if (constraints.TryGetValue("must_have_space", out var spaceStr) + && bool.TryParse(spaceStr, out var mustHaveSpace) + && mustHaveSpace) + { + if (!value.Contains(' ')) + return "Valor deve conter ao menos duas palavras"; + } + return null; } } diff --git a/src/Nalu.Api/Validators/validate_cnpj.md b/src/Nalu.Api/Validators/validate_cnpj.md index fcf754c..8595cf1 100644 --- a/src/Nalu.Api/Validators/validate_cnpj.md +++ b/src/Nalu.Api/Validators/validate_cnpj.md @@ -1,15 +1,15 @@ # validate_cnpj -Extrai e valida CNPJ brasileiro (14 dígitos com algoritmo mod 11). +Extrai e valida CNPJ brasileiro — numérico (formato atual) e alfanumérico (novo formato IN RFB 2229/2024). ## config - type: extraction -- version: 1.0 +- version: 1.1 - languages: pt-BR - endpoint: /v1/extract/cnpj - mcp_tool: nalu_extract_cnpj -- mcp_description: Extrai e valida CNPJ brasileiro (número de registro de empresa, 14 dígitos). Valida dígitos verificadores algoritmicamente (mod 11). Formata como XX.XXX.XXX/XXXX-XX. Se obtained=false, o CNPJ é inválido — use suggestion_to_agent para pedir novamente. Validador específico para o Brasil. +- mcp_description: Extrai e valida CNPJ brasileiro — suporta formato numérico clássico e o novo formato alfanumérico (IN RFB 2229/2024). Valida dígitos verificadores com mod 11. Formata como XX.XXX.XXX/XXXX-XX. Os dois últimos caracteres são sempre dígitos numéricos. Se obtained=false, use suggestion_to_agent para pedir novamente. ## deterministic_rules @@ -18,23 +18,27 @@ bom dia, boa tarde, boa noite, olá, oi, não tenho, nao tenho, não sei, sem cn ### reject_patterns - ^[a-zA-Z\s]+$ -- ^(\d)\1{13}$ +- ^(.)\1{13}$ ### accept_patterns -- (\d{2}[\.\s]?\d{3}[\.\s]?\d{3}[\/\s]?\d{4}[-\s]?\d{2}) -- (\d{14}) +- ([A-Z0-9]{2}\.?[A-Z0-9]{3}\.?[A-Z0-9]{3}\/?[A-Z0-9]{4}-?\d{2}) +- ([A-Z0-9]{12}\d{2}) ### constraints - min_length: 14 +- value_pattern: ^[A-Z0-9.\-/\s]{14,18}$ ## prompt Você é um extrator de CNPJ. Dado o diálogo abaixo, extraia o CNPJ que a empresa informou. +O CNPJ pode ser numérico (ex: 11.222.333/0001-81) ou alfanumérico no novo formato (ex: AB.CDE.FGH/0001-23), com 14 caracteres no total. Os dois últimos caracteres são sempre dígitos numéricos. + Regras: -1. Extraia apenas os 14 dígitos do CNPJ. -2. Se o usuário não forneceu CNPJ ou foi evasivo, retorne extracted_value: null. -3. Não valide o CNPJ — apenas extraia os dígitos. +1. Extraia os 14 caracteres do CNPJ (letras maiúsculas + dígitos), sem pontuação ou separadores. +2. Se o CNPJ tiver letras, mantenha-as em maiúsculas. +3. Se o usuário não forneceu CNPJ ou foi evasivo, retorne extracted_value: null. +4. Não valide o CNPJ — apenas extraia os caracteres. Diálogo: Agente: {{agent_input}} @@ -44,7 +48,7 @@ Contexto do agente: {{agent_context}} Responda SOMENTE com JSON válido, sem markdown: { - "extracted_value": "14 dígitos do CNPJ ou null", + "extracted_value": "14 caracteres do CNPJ sem formatação ou null", "certain": true/false, "reasoning": "explicação curta" } @@ -61,6 +65,11 @@ Responda SOMENTE com JSON válido, sem markdown: - user_input: é 11222333000181 - output: {"extracted_value": "11222333000181", "certain": true, "reasoning": "CNPJ sem formatação"} +### example 5 +- agent_input: Qual o CNPJ da empresa? +- user_input: AB.CDE.FGH/0001-23 +- output: {"extracted_value": "ABCDEFGH000123", "certain": true, "reasoning": "CNPJ alfanumérico novo formato"} + ### example 3 - agent_input: Qual o CNPJ da empresa? - user_input: não tenho aqui agora @@ -77,7 +86,7 @@ Responda SOMENTE com JSON válido, sem markdown: ## suggestions ### when_null_evasive -Preciso do CNPJ da empresa para continuar. Pode informar? São 14 dígitos (formato: XX.XXX.XXX/XXXX-XX) +Preciso do CNPJ da empresa para continuar. Pode informar? (formato: XX.XXX.XXX/XXXX-XX — numérico ou alfanumérico) ### when_invalid Esse CNPJ parece estar incorreto. Pode verificar? São 14 dígitos (XX.XXX.XXX/XXXX-XX). diff --git a/src/Nalu.Api/Validators/validate_cpf.md b/src/Nalu.Api/Validators/validate_cpf.md index c803b5b..11f19bd 100644 --- a/src/Nalu.Api/Validators/validate_cpf.md +++ b/src/Nalu.Api/Validators/validate_cpf.md @@ -25,6 +25,7 @@ bom dia, boa tarde, boa noite, olá, oi ### constraints - min_length: 11 +- value_pattern: ^[\d.\-\s]{11,14}$ ## prompt diff --git a/src/Nalu.Api/Validators/validate_full_name.md b/src/Nalu.Api/Validators/validate_full_name.md index 83c90db..620f860 100644 --- a/src/Nalu.Api/Validators/validate_full_name.md +++ b/src/Nalu.Api/Validators/validate_full_name.md @@ -1,15 +1,15 @@ # validate_full_name -Extrai o nome completo do usuário a partir do diálogo. +Extrai o nome completo do usuário (com sobrenome) a partir do diálogo. ## config - type: extraction - version: 1.0 - languages: pt-BR, es-ES, en-US -- endpoint: /v1/extract/name -- mcp_tool: nalu_extract_name -- mcp_description: Extrai o nome completo do usuário a partir da conversa. Use quando o agente perguntou o nome e o usuário respondeu. Retorna o nome extraído, nível de certeza e sugestão de fala para o agente. Se certain=true, aceite o valor. Se certain=false e suggestion_to_agent não é null, use a sugestão como próxima mensagem. Se obtained=false, use a sugestão para re-pedir o dado. +- endpoint: /v1/extract/full-name +- mcp_tool: nalu_extract_full_name +- mcp_description: Extrai o nome completo do usuário (nome + sobrenome). Use quando o agente precisa do nome completo para cadastro, contrato ou triagem. Retorna o nome extraído, nível de certeza e sugestão de fala. Se certain=true, aceite o valor. Se certain=false e suggestion_to_agent não é null, use a sugestão como próxima mensagem. Se obtained=false, use a sugestão para re-pedir o dado. ## deterministic_rules @@ -21,27 +21,30 @@ bom dia, boa tarde, boa noite, olá, oi, tudo bem, e aí, fala, eae, opa - ^\d+$ ### accept_patterns -- ^meu nome é\s+(.+)$ -- ^me chamo\s+(.+)$ -- ^sou o\s+(.+)$ -- ^sou a\s+(.+)$ -- ^pode me chamar de\s+(.+)$ +- (?i)^me chamo\s+(.+)$ +- (?i)^meu nome completo é\s+(.+)$ +- (?i)^my name is\s+(.+)$ +- (?i)^me llamo\s+(.+)$ +- ^([A-ZÁÉÍÓÚÀÂÊÔÃÕÇÜÑ][a-záéíóúàâêôãõçüñ'ã-]+(?:\s+(?:de|da|do|dos|das|e|[A-ZÁÉÍÓÚÀÂÊÔÃÕÇÜÑ][a-záéíóúàâêôãõçüñ'ã-]+)){1,})$ ### constraints - min_length: 2 - must_have_alpha: true +- must_have_space: true - max_length: 120 ## prompt -Você é um extrator de nomes. Dado o diálogo abaixo entre um agente e um usuário, extraia o nome completo que o usuário informou. +Você é um extrator de nomes completos. Dado o diálogo abaixo entre um agente e um usuário, extraia o nome completo (com sobrenome) que o usuário informou. Regras: -1. Se o usuário respondeu com saudação (bom dia, oi, etc.) e NÃO disse o nome, retorne extracted_value: null. -2. Se o usuário deu um nome que parece falso, zueira ou ofensivo (ex: "Xilofone", "Ninguém", "Seu Pai", "Não tenho"), retorne extracted_value com o nome mas certain: false. -3. Se o usuário deu um nome comum/plausível, retorne extracted_value com o nome e certain: true. -4. Nomes incomuns mas reais (ex: "Céu", "Lua", "Sol", "Índigo") devem retornar certain: false para o agente confirmar. -5. Normalize o nome com capitalização adequada (primeira letra maiúscula de cada palavra). +1. Se o usuário respondeu apenas com saudação (bom dia, oi, etc.) sem dizer o nome, retorne extracted_value: null. +2. Nome completo requer ao menos um sobrenome (duas palavras ou mais). Se só informou primeiro nome, retorne com o nome mas certain: false. +3. Extraia o nome completo e avalie: + - certain: true → nome + sobrenome claramente humanos em PT, EN ou ES (ex: "Maria Silva", "Carlos Eduardo Santos", "Michael Johnson", "Paulo da Silva"). + - certain: false → qualquer parte não é claramente humana: substantivo comum (Segredo Silva, Piano Souza), marca (Xerox Santos), instrumento/objeto (Xilofone Ninguém), palavra ofensiva/zueira, ou frase ambígua entre nome e recusa. +4. Se o usuário confirmou o nome após o agente perguntar, retorne certain: true mesmo se incomum. +5. Normalize: primeira letra maiúscula em cada palavra, exceto preposições (de/da/do/dos/das/e). Diálogo: Agente: {{agent_input}} @@ -64,29 +67,49 @@ Responda SOMENTE com JSON válido, sem markdown, sem explicação: - output: {"extracted_value": null, "certain": false, "reasoning": "Usuário respondeu com saudação, não informou o nome"} ### example 2 -- agent_input: Qual seu nome? -- user_input: Meu nome é xilofone -- output: {"extracted_value": "Xilofone", "certain": false, "reasoning": "Nome aparenta ser zueira, precisa confirmação"} +- agent_input: Qual seu nome completo? +- user_input: Carlos +- output: {"extracted_value": "Carlos", "certain": false, "reasoning": "Apenas primeiro nome, falta o sobrenome"} ### example 3 - agent_input: Para continuar, preciso do seu nome completo. - user_input: Maria Silva dos Santos -- output: {"extracted_value": "Maria Silva dos Santos", "certain": true, "reasoning": "Nome completo plausível informado diretamente"} +- output: {"extracted_value": "Maria Silva dos Santos", "certain": true, "reasoning": "Nome completo humano reconhecido"} ### example 4 +- agent_input: Qual seu nome completo? +- user_input: Ricardo Carneiro +- output: {"extracted_value": "Ricardo Carneiro", "certain": true, "reasoning": "Nome completo humano reconhecido em PT"} + +### example 5 - agent_input: Qual seu nome? - user_input: sei la - output: {"extracted_value": null, "certain": false, "reasoning": "Usuário foi evasivo, não informou nome"} -### example 5 -- agent_input: Tem certeza que seu nome é Cebola? -- user_input: Sim, quero que me chame de Cebola. -- output: {"extracted_value": "Cebola", "certain": true, "reasoning": "Usuário confirmou o nome após questionamento"} - ### example 6 -- agent_input: Qual seu nome? -- user_input: Céu Azul de Oliveira -- output: {"extracted_value": "Céu Azul de Oliveira", "certain": false, "reasoning": "Nome incomum, pode ser real mas precisa confirmação"} +- agent_input: Qual seu nome completo? +- user_input: Meu nome é segredo. +- output: {"extracted_value": "Segredo", "certain": false, "reasoning": "Ambíguo: recusa ou nome? Segredo é substantivo comum"} + +### example 7 +- agent_input: Qual seu nome completo? +- user_input: Segredo da Silva +- output: {"extracted_value": "Segredo da Silva", "certain": false, "reasoning": "Segredo é substantivo comum, não antropônimo"} + +### example 8 +- agent_input: What's your full name? +- user_input: Piano Smith +- output: {"extracted_value": "Piano Smith", "certain": false, "reasoning": "Piano is an instrument, not a human first name"} + +### example 9 +- agent_input: Qual seu nome completo? +- user_input: Carlos Eduardo Santos +- output: {"extracted_value": "Carlos Eduardo Santos", "certain": true, "reasoning": "Nome completo humano reconhecido"} + +### example 10 +- agent_input: Seu nome completo é Segredo da Silva? +- user_input: Sim, pode registrar assim. +- output: {"extracted_value": "Segredo da Silva", "certain": true, "reasoning": "Usuário confirmou o nome após questionamento"} ## post_processors - capitalize_proper_name @@ -104,7 +127,7 @@ Responda SOMENTE com JSON válido, sem markdown, sem explicação: Sem problemas, mas preciso do seu nome para prosseguir. Qual seu nome completo? ### when_uncertain -Só confirmando: seu nome é {{extracted_value}}? Pode confirmar? +Seu nome é {{extracted_value}}? Pode confirmar que posso registrá-lo assim? ### when_certain (sem sugestão — agente segue o fluxo) diff --git a/src/Nalu.Api/Validators/validate_yes_no.md b/src/Nalu.Api/Validators/validate_yes_no.md index d80d954..c42f596 100644 --- a/src/Nalu.Api/Validators/validate_yes_no.md +++ b/src/Nalu.Api/Validators/validate_yes_no.md @@ -23,6 +23,7 @@ bom dia, boa tarde, boa noite, olá, oi ### accept_patterns ### constraints +- value_pattern: ^(true|false)$ ## prompt diff --git a/src/Nalu.Jobs/CuratedNamesImporter.cs b/src/Nalu.Jobs/CuratedNamesImporter.cs new file mode 100644 index 0000000..3a1f0ef --- /dev/null +++ b/src/Nalu.Jobs/CuratedNamesImporter.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Nalu.Jobs; + +/// +/// Imports the curated Brazilian names list (Data/names_curated.json) into MongoDB. +/// Complements the IBGE importer with names not covered by the IBGE top-20 ranking. +/// Uses the same nomes_br collection and upsert logic. +/// +public static class CuratedNamesImporter +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true + }; + + private static readonly string[] EmbeddedFiles = + [ + "names_curated.json", + "names_curated_en.json", + "names_curated_es.json" + ]; + + public static async Task ImportAsync( + IMongoDatabase db, + ILogger logger, + CancellationToken ct) + { + var repo = new NameRepository(db); + await repo.EnsureIndexesAsync(ct); + + var now = DateTime.UtcNow; + int totalInserted = 0, totalUpdated = 0; + + foreach (var fileName in EmbeddedFiles) + { + var entries = LoadEmbeddedList(fileName); + logger.LogInformation("Curated list '{File}' loaded — {Count} entries", fileName, entries.Length); + + foreach (var entry in entries) + { + ct.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(entry.Nome)) continue; + + var aggregated = new AggregatedName( + Nome: entry.Nome.Trim().ToUpperInvariant(), + Frequencia: entry.Frequencia, + Genero: NormalizeGenero(entry.Genero)); + + var wasInserted = await repo.UpsertAsync(aggregated, now, ct); + if (wasInserted) totalInserted++; else totalUpdated++; + } + } + + logger.LogInformation( + "Curated import done — inserted={I} updated={U}", totalInserted, totalUpdated); + } + + private static CuratedEntry[] LoadEmbeddedList(string fileName) + { + var asm = typeof(CuratedNamesImporter).Assembly; + var resourceName = asm.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith(fileName, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"Embedded resource '{fileName}' not found."); + + using var stream = asm.GetManifestResourceStream(resourceName)!; + return JsonSerializer.Deserialize(stream, JsonOpts) + ?? throw new InvalidDataException($"'{fileName}' is empty or invalid."); + } + + private static string NormalizeGenero(string? g) => g?.ToUpperInvariant() switch + { + "M" => "M", + "F" => "F", + _ => "N" + }; + + private record CuratedEntry + { + [JsonPropertyName("nome")] public string Nome { get; init; } = ""; + [JsonPropertyName("genero")] public string? Genero { get; init; } + [JsonPropertyName("frequencia")] public long Frequencia { get; init; } + } +} diff --git a/src/Nalu.Jobs/Data/names_curated.json b/src/Nalu.Jobs/Data/names_curated.json new file mode 100644 index 0000000..8c878b6 --- /dev/null +++ b/src/Nalu.Jobs/Data/names_curated.json @@ -0,0 +1,379 @@ +[ + { "nome": "JOSE", "genero": "M", "frequencia": 5732508 }, + { "nome": "JOAO", "genero": "M", "frequencia": 2971935 }, + { "nome": "ANTONIO", "genero": "M", "frequencia": 2567494 }, + { "nome": "FRANCISCO", "genero": "M", "frequencia": 1765197 }, + { "nome": "CARLOS", "genero": "M", "frequencia": 1483121 }, + { "nome": "PAULO", "genero": "M", "frequencia": 1417907 }, + { "nome": "PEDRO", "genero": "M", "frequencia": 1213557 }, + { "nome": "LUCAS", "genero": "M", "frequencia": 1116818 }, + { "nome": "LUIZ", "genero": "M", "frequencia": 1102927 }, + { "nome": "MARCOS", "genero": "M", "frequencia": 1101126 }, + { "nome": "LUIS", "genero": "M", "frequencia": 931530 }, + { "nome": "GABRIEL", "genero": "M", "frequencia": 922744 }, + { "nome": "RAFAEL", "genero": "M", "frequencia": 814709 }, + { "nome": "DANIEL", "genero": "M", "frequencia": 706527 }, + { "nome": "MARCELO", "genero": "M", "frequencia": 690098 }, + { "nome": "BRUNO", "genero": "M", "frequencia": 663271 }, + { "nome": "EDUARDO", "genero": "M", "frequencia": 628539 }, + { "nome": "FELIPE", "genero": "M", "frequencia": 615924 }, + { "nome": "RAIMUNDO", "genero": "M", "frequencia": 611174 }, + { "nome": "RODRIGO", "genero": "M", "frequencia": 598825 }, + { "nome": "JORGE", "genero": "M", "frequencia": 560000 }, + { "nome": "MANOEL", "genero": "M", "frequencia": 540000 }, + { "nome": "ROBERTO", "genero": "M", "frequencia": 520000 }, + { "nome": "SERGIO", "genero": "M", "frequencia": 500000 }, + { "nome": "MARIO", "genero": "M", "frequencia": 490000 }, + { "nome": "ALEXANDRE", "genero": "M", "frequencia": 480000 }, + { "nome": "ANDRE", "genero": "M", "frequencia": 470000 }, + { "nome": "RICARDO", "genero": "M", "frequencia": 460000 }, + { "nome": "LEANDRO", "genero": "M", "frequencia": 450000 }, + { "nome": "DIEGO", "genero": "M", "frequencia": 440000 }, + { "nome": "LEONARDO", "genero": "M", "frequencia": 430000 }, + { "nome": "FERNANDO", "genero": "M", "frequencia": 420000 }, + { "nome": "GUSTAVO", "genero": "M", "frequencia": 415000 }, + { "nome": "THIAGO", "genero": "M", "frequencia": 410000 }, + { "nome": "VINICIUS", "genero": "M", "frequencia": 400000 }, + { "nome": "MATHEUS", "genero": "M", "frequencia": 395000 }, + { "nome": "HENRIQUE", "genero": "M", "frequencia": 390000 }, + { "nome": "GUILHERME", "genero": "M", "frequencia": 385000 }, + { "nome": "SAMUEL", "genero": "M", "frequencia": 380000 }, + { "nome": "ARTHUR", "genero": "M", "frequencia": 375000 }, + { "nome": "MIGUEL", "genero": "M", "frequencia": 370000 }, + { "nome": "DAVI", "genero": "M", "frequencia": 365000 }, + { "nome": "NICOLAS", "genero": "M", "frequencia": 360000 }, + { "nome": "FABIO", "genero": "M", "frequencia": 355000 }, + { "nome": "MARCIO", "genero": "M", "frequencia": 350000 }, + { "nome": "WAGNER", "genero": "M", "frequencia": 340000 }, + { "nome": "ROGERIO", "genero": "M", "frequencia": 330000 }, + { "nome": "RENATO", "genero": "M", "frequencia": 320000 }, + { "nome": "ADRIANO", "genero": "M", "frequencia": 315000 }, + { "nome": "CAIO", "genero": "M", "frequencia": 310000 }, + { "nome": "VITOR", "genero": "M", "frequencia": 305000 }, + { "nome": "MURILO", "genero": "M", "frequencia": 300000 }, + { "nome": "IGOR", "genero": "M", "frequencia": 295000 }, + { "nome": "DOUGLAS", "genero": "M", "frequencia": 290000 }, + { "nome": "ANDERSON", "genero": "M", "frequencia": 285000 }, + { "nome": "ALAN", "genero": "M", "frequencia": 280000 }, + { "nome": "CLAUDIO", "genero": "M", "frequencia": 275000 }, + { "nome": "EDSON", "genero": "M", "frequencia": 270000 }, + { "nome": "ELIAS", "genero": "M", "frequencia": 265000 }, + { "nome": "EMERSON", "genero": "M", "frequencia": 260000 }, + { "nome": "EVERTON", "genero": "M", "frequencia": 255000 }, + { "nome": "FLAVIO", "genero": "M", "frequencia": 250000 }, + { "nome": "GILBERTO", "genero": "M", "frequencia": 245000 }, + { "nome": "HELIO", "genero": "M", "frequencia": 240000 }, + { "nome": "IVAN", "genero": "M", "frequencia": 235000 }, + { "nome": "JEFFERSON", "genero": "M", "frequencia": 230000 }, + { "nome": "JULIO", "genero": "M", "frequencia": 225000 }, + { "nome": "LUAN", "genero": "M", "frequencia": 220000 }, + { "nome": "MICHEL", "genero": "M", "frequencia": 215000 }, + { "nome": "NATHAN", "genero": "M", "frequencia": 210000 }, + { "nome": "NILTON", "genero": "M", "frequencia": 205000 }, + { "nome": "OSVALDO", "genero": "M", "frequencia": 200000 }, + { "nome": "OTAVIO", "genero": "M", "frequencia": 195000 }, + { "nome": "RENAN", "genero": "M", "frequencia": 190000 }, + { "nome": "RONALDO", "genero": "M", "frequencia": 185000 }, + { "nome": "RUBENS", "genero": "M", "frequencia": 180000 }, + { "nome": "SANDRO", "genero": "M", "frequencia": 175000 }, + { "nome": "TIAGO", "genero": "M", "frequencia": 170000 }, + { "nome": "VALDIR", "genero": "M", "frequencia": 165000 }, + { "nome": "VICENTE", "genero": "M", "frequencia": 160000 }, + { "nome": "WELLINGTON", "genero": "M", "frequencia": 155000 }, + { "nome": "WESLEY", "genero": "M", "frequencia": 150000 }, + { "nome": "WILLIAM", "genero": "M", "frequencia": 145000 }, + { "nome": "YURI", "genero": "M", "frequencia": 140000 }, + { "nome": "EDER", "genero": "M", "frequencia": 135000 }, + { "nome": "EZEQUIEL", "genero": "M", "frequencia": 130000 }, + { "nome": "JAIR", "genero": "M", "frequencia": 125000 }, + { "nome": "KLEBER", "genero": "M", "frequencia": 120000 }, + { "nome": "MATEUS", "genero": "M", "frequencia": 115000 }, + { "nome": "NELSON", "genero": "M", "frequencia": 110000 }, + { "nome": "NILSON", "genero": "M", "frequencia": 105000 }, + { "nome": "ORLANDO", "genero": "M", "frequencia": 100000 }, + { "nome": "OSCAR", "genero": "M", "frequencia": 95000 }, + { "nome": "REGINALDO", "genero": "M", "frequencia": 90000 }, + { "nome": "SEBASTIAO", "genero": "M", "frequencia": 85000 }, + { "nome": "SILVIO", "genero": "M", "frequencia": 80000 }, + { "nome": "VALDECIR", "genero": "M", "frequencia": 75000 }, + { "nome": "VALTER", "genero": "M", "frequencia": 70000 }, + { "nome": "WANDERLEY", "genero": "M", "frequencia": 65000 }, + { "nome": "YAGO", "genero": "M", "frequencia": 60000 }, + { "nome": "BENEDITO", "genero": "M", "frequencia": 58000 }, + { "nome": "GERALDO", "genero": "M", "frequencia": 57000 }, + { "nome": "ARNALDO", "genero": "M", "frequencia": 56000 }, + { "nome": "ALFREDO", "genero": "M", "frequencia": 55000 }, + { "nome": "ARMANDO", "genero": "M", "frequencia": 54000 }, + { "nome": "ALMIR", "genero": "M", "frequencia": 53000 }, + { "nome": "ALTAIR", "genero": "M", "frequencia": 52000 }, + { "nome": "ALVARO", "genero": "M", "frequencia": 51000 }, + { "nome": "ARTUR", "genero": "M", "frequencia": 50000 }, + { "nome": "AURELIANO", "genero": "M", "frequencia": 49000 }, + { "nome": "AURELIO", "genero": "M", "frequencia": 48000 }, + { "nome": "BENTO", "genero": "M", "frequencia": 47000 }, + { "nome": "CELSO", "genero": "M", "frequencia": 46000 }, + { "nome": "CELIO", "genero": "M", "frequencia": 45000 }, + { "nome": "DALTON", "genero": "M", "frequencia": 44000 }, + { "nome": "DAMIAO", "genero": "M", "frequencia": 43000 }, + { "nome": "DANTE", "genero": "M", "frequencia": 42000 }, + { "nome": "DARIO", "genero": "M", "frequencia": 41000 }, + { "nome": "DENILSON", "genero": "M", "frequencia": 40000 }, + { "nome": "EDVALDO", "genero": "M", "frequencia": 39000 }, + { "nome": "ELTON", "genero": "M", "frequencia": 38000 }, + { "nome": "EUCLIDES", "genero": "M", "frequencia": 37000 }, + { "nome": "EURICO", "genero": "M", "frequencia": 36000 }, + { "nome": "EVALDO", "genero": "M", "frequencia": 35000 }, + { "nome": "FABRICIO", "genero": "M", "frequencia": 34000 }, + { "nome": "FIRMINO", "genero": "M", "frequencia": 33000 }, + { "nome": "GASPAR", "genero": "M", "frequencia": 32000 }, + { "nome": "GENIVALDO", "genero": "M", "frequencia": 31000 }, + { "nome": "HEITOR", "genero": "M", "frequencia": 30000 }, + { "nome": "HUMBERTO", "genero": "M", "frequencia": 29000 }, + { "nome": "IRINEU", "genero": "M", "frequencia": 28000 }, + { "nome": "ISAIAS", "genero": "M", "frequencia": 27000 }, + { "nome": "ITAMAR", "genero": "M", "frequencia": 26000 }, + { "nome": "JAIME", "genero": "M", "frequencia": 25000 }, + { "nome": "JARBAS", "genero": "M", "frequencia": 24000 }, + { "nome": "JEREMIAS", "genero": "M", "frequencia": 23000 }, + { "nome": "JOEL", "genero": "M", "frequencia": 22000 }, + { "nome": "JONATHAN", "genero": "M", "frequencia": 21500 }, + { "nome": "JOSUE", "genero": "M", "frequencia": 21000 }, + { "nome": "LAERCIO", "genero": "M", "frequencia": 20500 }, + { "nome": "LAURO", "genero": "M", "frequencia": 20000 }, + { "nome": "LINDOMAR", "genero": "M", "frequencia": 19500 }, + { "nome": "LUCIO", "genero": "M", "frequencia": 19000 }, + { "nome": "MESSIAS", "genero": "M", "frequencia": 18500 }, + { "nome": "MOACIR", "genero": "M", "frequencia": 18000 }, + { "nome": "MOISES", "genero": "M", "frequencia": 17500 }, + { "nome": "NAPOLEAO", "genero": "M", "frequencia": 17000 }, + { "nome": "NILDO", "genero": "M", "frequencia": 16500 }, + { "nome": "NOEL", "genero": "M", "frequencia": 16000 }, + { "nome": "NORBERTO", "genero": "M", "frequencia": 15500 }, + { "nome": "OLAVO", "genero": "M", "frequencia": 15000 }, + { "nome": "OLIMPIO", "genero": "M", "frequencia": 14500 }, + { "nome": "ONILDO", "genero": "M", "frequencia": 14000 }, + { "nome": "ORESTES", "genero": "M", "frequencia": 13500 }, + { "nome": "OTONIEL", "genero": "M", "frequencia": 13000 }, + { "nome": "PATRICIO", "genero": "M", "frequencia": 12500 }, + { "nome": "PERCIVAL", "genero": "M", "frequencia": 12000 }, + { "nome": "PLINIO", "genero": "M", "frequencia": 11500 }, + { "nome": "QUIRINO", "genero": "M", "frequencia": 11000 }, + { "nome": "RAMIRO", "genero": "M", "frequencia": 10500 }, + { "nome": "RANGEL", "genero": "M", "frequencia": 10000 }, + { "nome": "RINALDO", "genero": "M", "frequencia": 9500 }, + { "nome": "ROMARIO", "genero": "M", "frequencia": 9000 }, + { "nome": "ROMULO", "genero": "M", "frequencia": 8500 }, + { "nome": "RUBEM", "genero": "M", "frequencia": 8000 }, + { "nome": "SABINO", "genero": "M", "frequencia": 7500 }, + { "nome": "SALOMAO", "genero": "M", "frequencia": 7000 }, + { "nome": "SAULO", "genero": "M", "frequencia": 6500 }, + { "nome": "SIDNEI", "genero": "M", "frequencia": 6000 }, + { "nome": "TANCREDO", "genero": "M", "frequencia": 5500 }, + { "nome": "TARCISIO", "genero": "M", "frequencia": 5000 }, + { "nome": "TEODORO", "genero": "M", "frequencia": 4800 }, + { "nome": "TIMOTEO", "genero": "M", "frequencia": 4600 }, + { "nome": "TOBIAS", "genero": "M", "frequencia": 4400 }, + { "nome": "TULIO", "genero": "M", "frequencia": 4200 }, + { "nome": "ULISSES", "genero": "M", "frequencia": 4000 }, + { "nome": "VALENTIM", "genero": "M", "frequencia": 3800 }, + { "nome": "VANDERLEI", "genero": "M", "frequencia": 3600 }, + { "nome": "VENANCIO", "genero": "M", "frequencia": 3400 }, + { "nome": "ZACARIAS", "genero": "M", "frequencia": 3200 }, + { "nome": "ZAQUEU", "genero": "M", "frequencia": 3000 }, + { "nome": "ADRIEL", "genero": "M", "frequencia": 2800 }, + { "nome": "CAUÃ", "genero": "M", "frequencia": 2600 }, + { "nome": "ENZO", "genero": "M", "frequencia": 2400 }, + { "nome": "GIOVANNI", "genero": "M", "frequencia": 2200 }, + { "nome": "IAN", "genero": "M", "frequencia": 2000 }, + { "nome": "KAUÃ", "genero": "M", "frequencia": 1900 }, + { "nome": "KEVYN", "genero": "M", "frequencia": 1800 }, + { "nome": "NATAN", "genero": "M", "frequencia": 1700 }, + { "nome": "RYAN", "genero": "M", "frequencia": 1600 }, + { "nome": "THEO", "genero": "M", "frequencia": 1500 }, + { "nome": "BENICIO", "genero": "M", "frequencia": 1400 }, + { "nome": "HEITOR", "genero": "M", "frequencia": 30000 }, + { "nome": "ENZO", "genero": "M", "frequencia": 2400 }, + + { "nome": "MARIA", "genero": "F", "frequencia": 11694738 }, + { "nome": "ANA", "genero": "F", "frequencia": 3079729 }, + { "nome": "FRANCISCA", "genero": "F", "frequencia": 721637 }, + { "nome": "ANTONIA", "genero": "F", "frequencia": 588783 }, + { "nome": "ADRIANA", "genero": "F", "frequencia": 565621 }, + { "nome": "JULIANA", "genero": "F", "frequencia": 562589 }, + { "nome": "MARCIA", "genero": "F", "frequencia": 551855 }, + { "nome": "FERNANDA", "genero": "F", "frequencia": 531607 }, + { "nome": "PATRICIA", "genero": "F", "frequencia": 529446 }, + { "nome": "ALINE", "genero": "F", "frequencia": 509869 }, + { "nome": "SANDRA", "genero": "F", "frequencia": 479230 }, + { "nome": "CAMILA", "genero": "F", "frequencia": 469851 }, + { "nome": "AMANDA", "genero": "F", "frequencia": 464624 }, + { "nome": "BRUNA", "genero": "F", "frequencia": 460770 }, + { "nome": "JESSICA", "genero": "F", "frequencia": 456472 }, + { "nome": "LETICIA", "genero": "F", "frequencia": 434056 }, + { "nome": "JULIA", "genero": "F", "frequencia": 430067 }, + { "nome": "LUCIANA", "genero": "F", "frequencia": 429769 }, + { "nome": "VANESSA", "genero": "F", "frequencia": 417512 }, + { "nome": "MARIANA", "genero": "F", "frequencia": 381778 }, + { "nome": "CLAUDIA", "genero": "F", "frequencia": 370000 }, + { "nome": "DANIELA", "genero": "F", "frequencia": 360000 }, + { "nome": "CRISTIANE", "genero": "F", "frequencia": 350000 }, + { "nome": "CRISTINA", "genero": "F", "frequencia": 340000 }, + { "nome": "DEBORA", "genero": "F", "frequencia": 330000 }, + { "nome": "DENISE", "genero": "F", "frequencia": 320000 }, + { "nome": "ELAINE", "genero": "F", "frequencia": 310000 }, + { "nome": "ERICA", "genero": "F", "frequencia": 300000 }, + { "nome": "FABIANA", "genero": "F", "frequencia": 290000 }, + { "nome": "GABRIELA", "genero": "F", "frequencia": 280000 }, + { "nome": "GISELE", "genero": "F", "frequencia": 270000 }, + { "nome": "INGRID", "genero": "F", "frequencia": 260000 }, + { "nome": "ISABELA", "genero": "F", "frequencia": 250000 }, + { "nome": "ISABEL", "genero": "F", "frequencia": 240000 }, + { "nome": "JAQUELINE", "genero": "F", "frequencia": 230000 }, + { "nome": "KAREN", "genero": "F", "frequencia": 220000 }, + { "nome": "KARLA", "genero": "F", "frequencia": 210000 }, + { "nome": "KARINE", "genero": "F", "frequencia": 200000 }, + { "nome": "LARA", "genero": "F", "frequencia": 195000 }, + { "nome": "LAURA", "genero": "F", "frequencia": 190000 }, + { "nome": "LARISSA", "genero": "F", "frequencia": 185000 }, + { "nome": "LUANA", "genero": "F", "frequencia": 180000 }, + { "nome": "MELISSA", "genero": "F", "frequencia": 175000 }, + { "nome": "MICHELE", "genero": "F", "frequencia": 170000 }, + { "nome": "MILENA", "genero": "F", "frequencia": 165000 }, + { "nome": "MONICA", "genero": "F", "frequencia": 160000 }, + { "nome": "NATHALIA", "genero": "F", "frequencia": 155000 }, + { "nome": "NATALIA", "genero": "F", "frequencia": 150000 }, + { "nome": "PAMELA", "genero": "F", "frequencia": 145000 }, + { "nome": "PAULA", "genero": "F", "frequencia": 140000 }, + { "nome": "PRISCILA", "genero": "F", "frequencia": 135000 }, + { "nome": "RAFAELA", "genero": "F", "frequencia": 130000 }, + { "nome": "RAQUEL", "genero": "F", "frequencia": 125000 }, + { "nome": "REGINA", "genero": "F", "frequencia": 120000 }, + { "nome": "RENATA", "genero": "F", "frequencia": 115000 }, + { "nome": "ROBERTA", "genero": "F", "frequencia": 110000 }, + { "nome": "ROSA", "genero": "F", "frequencia": 105000 }, + { "nome": "ROSANA", "genero": "F", "frequencia": 100000 }, + { "nome": "SABRINA", "genero": "F", "frequencia": 95000 }, + { "nome": "SAMANTHA", "genero": "F", "frequencia": 90000 }, + { "nome": "SARA", "genero": "F", "frequencia": 85000 }, + { "nome": "SIMONE", "genero": "F", "frequencia": 80000 }, + { "nome": "SONIA", "genero": "F", "frequencia": 75000 }, + { "nome": "SUELI", "genero": "F", "frequencia": 70000 }, + { "nome": "TALITA", "genero": "F", "frequencia": 65000 }, + { "nome": "TATIANA", "genero": "F", "frequencia": 60000 }, + { "nome": "THAIS", "genero": "F", "frequencia": 55000 }, + { "nome": "VERONICA", "genero": "F", "frequencia": 50000 }, + { "nome": "VIVIANE", "genero": "F", "frequencia": 45000 }, + { "nome": "YASMIN", "genero": "F", "frequencia": 40000 }, + { "nome": "YARA", "genero": "F", "frequencia": 38000 }, + { "nome": "BEATRIZ", "genero": "F", "frequencia": 36000 }, + { "nome": "BIANCA", "genero": "F", "frequencia": 34000 }, + { "nome": "CAROLINA", "genero": "F", "frequencia": 32000 }, + { "nome": "CARLA", "genero": "F", "frequencia": 30000 }, + { "nome": "CECILIA", "genero": "F", "frequencia": 28000 }, + { "nome": "DAIANE", "genero": "F", "frequencia": 26000 }, + { "nome": "ELISA", "genero": "F", "frequencia": 24000 }, + { "nome": "EMILIA", "genero": "F", "frequencia": 22000 }, + { "nome": "GIOVANNA", "genero": "F", "frequencia": 20000 }, + { "nome": "GLORIA", "genero": "F", "frequencia": 19000 }, + { "nome": "HELENA", "genero": "F", "frequencia": 18000 }, + { "nome": "HELOISA", "genero": "F", "frequencia": 17000 }, + { "nome": "INES", "genero": "F", "frequencia": 16000 }, + { "nome": "IRIS", "genero": "F", "frequencia": 15000 }, + { "nome": "JOANA", "genero": "F", "frequencia": 14000 }, + { "nome": "LEILA", "genero": "F", "frequencia": 13000 }, + { "nome": "LIDIA", "genero": "F", "frequencia": 12000 }, + { "nome": "LILIAN", "genero": "F", "frequencia": 11000 }, + { "nome": "LORENA", "genero": "F", "frequencia": 10500 }, + { "nome": "LUZIA", "genero": "F", "frequencia": 10000 }, + { "nome": "MADALENA", "genero": "F", "frequencia": 9500 }, + { "nome": "MIRIAM", "genero": "F", "frequencia": 9000 }, + { "nome": "NADIA", "genero": "F", "frequencia": 8500 }, + { "nome": "NOEMIA", "genero": "F", "frequencia": 8000 }, + { "nome": "ODETE", "genero": "F", "frequencia": 7500 }, + { "nome": "OLGA", "genero": "F", "frequencia": 7000 }, + { "nome": "RUTH", "genero": "F", "frequencia": 6500 }, + { "nome": "SELMA", "genero": "F", "frequencia": 6000 }, + { "nome": "SILVIA", "genero": "F", "frequencia": 5500 }, + { "nome": "SORAIA", "genero": "F", "frequencia": 5000 }, + { "nome": "SUZANA", "genero": "F", "frequencia": 4800 }, + { "nome": "TEREZA", "genero": "F", "frequencia": 4600 }, + { "nome": "TERESA", "genero": "F", "frequencia": 4400 }, + { "nome": "VALERIA", "genero": "F", "frequencia": 4200 }, + { "nome": "VITORIA", "genero": "F", "frequencia": 4000 }, + { "nome": "ZENAIDE", "genero": "F", "frequencia": 3800 }, + { "nome": "APARECIDA", "genero": "F", "frequencia": 3600 }, + { "nome": "BENEDITA", "genero": "F", "frequencia": 3400 }, + { "nome": "CONCEICAO", "genero": "F", "frequencia": 3200 }, + { "nome": "DALVA", "genero": "F", "frequencia": 3000 }, + { "nome": "DIRCE", "genero": "F", "frequencia": 2800 }, + { "nome": "EDNA", "genero": "F", "frequencia": 2600 }, + { "nome": "ELZA", "genero": "F", "frequencia": 2400 }, + { "nome": "EUNICE", "genero": "F", "frequencia": 2200 }, + { "nome": "FATIMA", "genero": "F", "frequencia": 2000 }, + { "nome": "GERALDA", "genero": "F", "frequencia": 1900 }, + { "nome": "HILDA", "genero": "F", "frequencia": 1800 }, + { "nome": "IRACEMA", "genero": "F", "frequencia": 1700 }, + { "nome": "IRENE", "genero": "F", "frequencia": 1600 }, + { "nome": "IOLANDA", "genero": "F", "frequencia": 1500 }, + { "nome": "IVONE", "genero": "F", "frequencia": 1400 }, + { "nome": "JANETE", "genero": "F", "frequencia": 1350 }, + { "nome": "JOSEFA", "genero": "F", "frequencia": 1300 }, + { "nome": "JOSIANE", "genero": "F", "frequencia": 1250 }, + { "nome": "LOURDES", "genero": "F", "frequencia": 1200 }, + { "nome": "LUCIENE", "genero": "F", "frequencia": 1150 }, + { "nome": "MARTA", "genero": "F", "frequencia": 1100 }, + { "nome": "MATILDE", "genero": "F", "frequencia": 1050 }, + { "nome": "NAIARA", "genero": "F", "frequencia": 1000 }, + { "nome": "NATALINA", "genero": "F", "frequencia": 950 }, + { "nome": "NAZARE", "genero": "F", "frequencia": 900 }, + { "nome": "NELMA", "genero": "F", "frequencia": 850 }, + { "nome": "NILDA", "genero": "F", "frequencia": 800 }, + { "nome": "OLINDA", "genero": "F", "frequencia": 750 }, + { "nome": "RAISSA", "genero": "F", "frequencia": 700 }, + { "nome": "RAMONA", "genero": "F", "frequencia": 680 }, + { "nome": "RAYSSA", "genero": "F", "frequencia": 660 }, + { "nome": "REBECA", "genero": "F", "frequencia": 640 }, + { "nome": "REJANE", "genero": "F", "frequencia": 620 }, + { "nome": "RITA", "genero": "F", "frequencia": 600 }, + { "nome": "ROSALIA", "genero": "F", "frequencia": 580 }, + { "nome": "ROSANGELA", "genero": "F", "frequencia": 560 }, + { "nome": "ROSELI", "genero": "F", "frequencia": 540 }, + { "nome": "ROSIMEIRE", "genero": "F", "frequencia": 520 }, + { "nome": "RUTE", "genero": "F", "frequencia": 500 }, + { "nome": "SALOME", "genero": "F", "frequencia": 480 }, + { "nome": "SELENE", "genero": "F", "frequencia": 460 }, + { "nome": "SHIRLEI", "genero": "F", "frequencia": 440 }, + { "nome": "SHIRLEY", "genero": "F", "frequencia": 420 }, + { "nome": "SILVANA", "genero": "F", "frequencia": 400 }, + { "nome": "SOCORRO", "genero": "F", "frequencia": 380 }, + { "nome": "SUELLEN", "genero": "F", "frequencia": 360 }, + { "nome": "TAINA", "genero": "F", "frequencia": 340 }, + { "nome": "TAMARA", "genero": "F", "frequencia": 320 }, + { "nome": "TAMIRES", "genero": "F", "frequencia": 300 }, + { "nome": "TANIA", "genero": "F", "frequencia": 280 }, + { "nome": "TATIANE", "genero": "F", "frequencia": 260 }, + { "nome": "THALITA", "genero": "F", "frequencia": 240 }, + { "nome": "VALENTINA", "genero": "F", "frequencia": 220 }, + { "nome": "VANDA", "genero": "F", "frequencia": 200 }, + { "nome": "VERA", "genero": "F", "frequencia": 190 }, + { "nome": "VILMA", "genero": "F", "frequencia": 180 }, + { "nome": "VIVIAN", "genero": "F", "frequencia": 170 }, + { "nome": "WANIA", "genero": "F", "frequencia": 160 }, + { "nome": "ALICE", "genero": "F", "frequencia": 150 }, + { "nome": "SOPHIA", "genero": "F", "frequencia": 140 }, + { "nome": "ISABELLA", "genero": "F", "frequencia": 130 }, + { "nome": "MANUELA", "genero": "F", "frequencia": 120 }, + { "nome": "LIVIA", "genero": "F", "frequencia": 110 }, + { "nome": "NAYARA", "genero": "F", "frequencia": 100 }, + { "nome": "ISADORA", "genero": "F", "frequencia": 90 }, + { "nome": "CLARA", "genero": "F", "frequencia": 80 }, + + { "nome": "ALEX", "genero": "N", "frequencia": 200000 }, + { "nome": "ARIEL", "genero": "N", "frequencia": 50000 }, + { "nome": "CHRISTIAN", "genero": "N", "frequencia": 40000 }, + { "nome": "RIAN", "genero": "N", "frequencia": 30000 }, + { "nome": "SASHA", "genero": "N", "frequencia": 20000 }, + { "nome": "DARA", "genero": "N", "frequencia": 15000 }, + { "nome": "EDER", "genero": "N", "frequencia": 10000 }, + { "nome": "GIOVANI", "genero": "N", "frequencia": 8000 } +] diff --git a/src/Nalu.Jobs/Data/names_curated_en.json b/src/Nalu.Jobs/Data/names_curated_en.json new file mode 100644 index 0000000..0456534 --- /dev/null +++ b/src/Nalu.Jobs/Data/names_curated_en.json @@ -0,0 +1,225 @@ +[ + {"nome": "JAMES", "genero": "M", "frequencia": 4900000}, + {"nome": "JOHN", "genero": "M", "frequencia": 4700000}, + {"nome": "ROBERT", "genero": "M", "frequencia": 4500000}, + {"nome": "MICHAEL", "genero": "M", "frequencia": 4300000}, + {"nome": "WILLIAM", "genero": "M", "frequencia": 4100000}, + {"nome": "DAVID", "genero": "M", "frequencia": 3900000}, + {"nome": "RICHARD", "genero": "M", "frequencia": 3200000}, + {"nome": "JOSEPH", "genero": "M", "frequencia": 3100000}, + {"nome": "THOMAS", "genero": "M", "frequencia": 3000000}, + {"nome": "CHARLES", "genero": "M", "frequencia": 2900000}, + {"nome": "CHRISTOPHER","genero": "M", "frequencia": 2700000}, + {"nome": "DANIEL", "genero": "M", "frequencia": 2600000}, + {"nome": "MATTHEW", "genero": "M", "frequencia": 2500000}, + {"nome": "ANTHONY", "genero": "M", "frequencia": 2400000}, + {"nome": "MARK", "genero": "M", "frequencia": 2300000}, + {"nome": "DONALD", "genero": "M", "frequencia": 2200000}, + {"nome": "STEVEN", "genero": "M", "frequencia": 2100000}, + {"nome": "PAUL", "genero": "M", "frequencia": 2000000}, + {"nome": "ANDREW", "genero": "M", "frequencia": 1900000}, + {"nome": "KENNETH", "genero": "M", "frequencia": 1800000}, + {"nome": "GEORGE", "genero": "M", "frequencia": 1750000}, + {"nome": "JOSHUA", "genero": "M", "frequencia": 1700000}, + {"nome": "KEVIN", "genero": "M", "frequencia": 1650000}, + {"nome": "BRIAN", "genero": "M", "frequencia": 1600000}, + {"nome": "EDWARD", "genero": "M", "frequencia": 1550000}, + {"nome": "RONALD", "genero": "M", "frequencia": 1500000}, + {"nome": "TIMOTHY", "genero": "M", "frequencia": 1450000}, + {"nome": "JASON", "genero": "M", "frequencia": 1400000}, + {"nome": "JEFFREY", "genero": "M", "frequencia": 1350000}, + {"nome": "RYAN", "genero": "M", "frequencia": 1300000}, + {"nome": "JACOB", "genero": "M", "frequencia": 1280000}, + {"nome": "GARY", "genero": "M", "frequencia": 1260000}, + {"nome": "NICHOLAS", "genero": "M", "frequencia": 1240000}, + {"nome": "ERIC", "genero": "M", "frequencia": 1220000}, + {"nome": "JONATHAN", "genero": "M", "frequencia": 1200000}, + {"nome": "STEPHEN", "genero": "M", "frequencia": 1180000}, + {"nome": "LARRY", "genero": "M", "frequencia": 1160000}, + {"nome": "JUSTIN", "genero": "M", "frequencia": 1140000}, + {"nome": "SCOTT", "genero": "M", "frequencia": 1120000}, + {"nome": "BRANDON", "genero": "M", "frequencia": 1100000}, + {"nome": "FRANK", "genero": "M", "frequencia": 1080000}, + {"nome": "RAYMOND", "genero": "M", "frequencia": 1060000}, + {"nome": "GREGORY", "genero": "M", "frequencia": 1040000}, + {"nome": "SAMUEL", "genero": "M", "frequencia": 1020000}, + {"nome": "PATRICK", "genero": "M", "frequencia": 1000000}, + {"nome": "BENJAMIN", "genero": "M", "frequencia": 980000}, + {"nome": "JACK", "genero": "M", "frequencia": 960000}, + {"nome": "DENNIS", "genero": "M", "frequencia": 940000}, + {"nome": "JERRY", "genero": "M", "frequencia": 920000}, + {"nome": "ALEXANDER", "genero": "M", "frequencia": 900000}, + {"nome": "TYLER", "genero": "M", "frequencia": 880000}, + {"nome": "DOUGLAS", "genero": "M", "frequencia": 860000}, + {"nome": "HENRY", "genero": "M", "frequencia": 840000}, + {"nome": "PETER", "genero": "M", "frequencia": 820000}, + {"nome": "ADAM", "genero": "M", "frequencia": 800000}, + {"nome": "NATHAN", "genero": "M", "frequencia": 780000}, + {"nome": "ZACHARY", "genero": "M", "frequencia": 760000}, + {"nome": "WALTER", "genero": "M", "frequencia": 740000}, + {"nome": "KYLE", "genero": "M", "frequencia": 720000}, + {"nome": "HAROLD", "genero": "M", "frequencia": 700000}, + {"nome": "CARL", "genero": "M", "frequencia": 680000}, + {"nome": "ARTHUR", "genero": "M", "frequencia": 660000}, + {"nome": "GERALD", "genero": "M", "frequencia": 640000}, + {"nome": "ROGER", "genero": "M", "frequencia": 620000}, + {"nome": "KEITH", "genero": "M", "frequencia": 600000}, + {"nome": "JEREMY", "genero": "M", "frequencia": 580000}, + {"nome": "TERRY", "genero": "M", "frequencia": 560000}, + {"nome": "LAWRENCE", "genero": "M", "frequencia": 540000}, + {"nome": "SEAN", "genero": "M", "frequencia": 520000}, + {"nome": "ALBERT", "genero": "M", "frequencia": 500000}, + {"nome": "DYLAN", "genero": "M", "frequencia": 490000}, + {"nome": "AUSTIN", "genero": "M", "frequencia": 480000}, + {"nome": "NOAH", "genero": "M", "frequencia": 470000}, + {"nome": "ETHAN", "genero": "M", "frequencia": 460000}, + {"nome": "CHRISTIAN", "genero": "M", "frequencia": 450000}, + {"nome": "ALAN", "genero": "M", "frequencia": 440000}, + {"nome": "WAYNE", "genero": "M", "frequencia": 430000}, + {"nome": "ROY", "genero": "M", "frequencia": 420000}, + {"nome": "RALPH", "genero": "M", "frequencia": 410000}, + {"nome": "EUGENE", "genero": "M", "frequencia": 400000}, + {"nome": "RUSSELL", "genero": "M", "frequencia": 390000}, + {"nome": "BOBBY", "genero": "M", "frequencia": 380000}, + {"nome": "PHILIP", "genero": "M", "frequencia": 370000}, + {"nome": "WAYNE", "genero": "M", "frequencia": 360000}, + {"nome": "LOUIS", "genero": "M", "frequencia": 350000}, + {"nome": "BILLY", "genero": "M", "frequencia": 340000}, + {"nome": "WILLIE", "genero": "M", "frequencia": 330000}, + {"nome": "BRUCE", "genero": "M", "frequencia": 320000}, + {"nome": "LIAM", "genero": "M", "frequencia": 310000}, + {"nome": "MASON", "genero": "M", "frequencia": 300000}, + {"nome": "LOGAN", "genero": "M", "frequencia": 290000}, + {"nome": "LUCAS", "genero": "M", "frequencia": 280000}, + {"nome": "OLIVER", "genero": "M", "frequencia": 270000}, + {"nome": "AIDEN", "genero": "M", "frequencia": 260000}, + {"nome": "CALEB", "genero": "M", "frequencia": 250000}, + {"nome": "ELIJAH", "genero": "M", "frequencia": 240000}, + {"nome": "GABRIEL", "genero": "M", "frequencia": 230000}, + {"nome": "ISAIAH", "genero": "M", "frequencia": 220000}, + {"nome": "HUNTER", "genero": "M", "frequencia": 210000}, + {"nome": "EVAN", "genero": "M", "frequencia": 200000}, + {"nome": "COLE", "genero": "M", "frequencia": 190000}, + {"nome": "CAMERON", "genero": "M", "frequencia": 180000}, + {"nome": "SEAN", "genero": "M", "frequencia": 170000}, + {"nome": "CONNOR", "genero": "M", "frequencia": 160000}, + {"nome": "CHARLIE", "genero": "M", "frequencia": 150000}, + {"nome": "MIKE", "genero": "M", "frequencia": 140000}, + {"nome": "JIMMY", "genero": "M", "frequencia": 130000}, + {"nome": "TOM", "genero": "M", "frequencia": 120000}, + {"nome": "JIM", "genero": "M", "frequencia": 110000}, + {"nome": "BOB", "genero": "M", "frequencia": 100000}, + {"nome": "BILL", "genero": "M", "frequencia": 90000}, + {"nome": "ALEX", "genero": "N", "frequencia": 80000}, + {"nome": "ANDY", "genero": "N", "frequencia": 70000}, + {"nome": "CHRIS", "genero": "N", "frequencia": 60000}, + {"nome": "SAM", "genero": "N", "frequencia": 50000}, + {"nome": "JORDAN", "genero": "N", "frequencia": 48000}, + {"nome": "TAYLOR", "genero": "N", "frequencia": 46000}, + {"nome": "MORGAN", "genero": "N", "frequencia": 44000}, + {"nome": "CASEY", "genero": "N", "frequencia": 42000}, + {"nome": "RILEY", "genero": "N", "frequencia": 40000}, + {"nome": "JAMIE", "genero": "N", "frequencia": 38000}, + {"nome": "MARY", "genero": "F", "frequencia": 3800000}, + {"nome": "PATRICIA", "genero": "F", "frequencia": 3600000}, + {"nome": "JENNIFER", "genero": "F", "frequencia": 3400000}, + {"nome": "LINDA", "genero": "F", "frequencia": 3200000}, + {"nome": "BARBARA", "genero": "F", "frequencia": 3000000}, + {"nome": "ELIZABETH", "genero": "F", "frequencia": 2800000}, + {"nome": "SUSAN", "genero": "F", "frequencia": 2600000}, + {"nome": "JESSICA", "genero": "F", "frequencia": 2400000}, + {"nome": "SARAH", "genero": "F", "frequencia": 2200000}, + {"nome": "KAREN", "genero": "F", "frequencia": 2000000}, + {"nome": "LISA", "genero": "F", "frequencia": 1900000}, + {"nome": "NANCY", "genero": "F", "frequencia": 1800000}, + {"nome": "BETTY", "genero": "F", "frequencia": 1700000}, + {"nome": "MARGARET", "genero": "F", "frequencia": 1600000}, + {"nome": "SANDRA", "genero": "F", "frequencia": 1500000}, + {"nome": "ASHLEY", "genero": "F", "frequencia": 1400000}, + {"nome": "EMILY", "genero": "F", "frequencia": 1350000}, + {"nome": "DOROTHY", "genero": "F", "frequencia": 1300000}, + {"nome": "KIMBERLY", "genero": "F", "frequencia": 1250000}, + {"nome": "AMY", "genero": "F", "frequencia": 1200000}, + {"nome": "SHIRLEY", "genero": "F", "frequencia": 1150000}, + {"nome": "ANGELA", "genero": "F", "frequencia": 1100000}, + {"nome": "HELEN", "genero": "F", "frequencia": 1050000}, + {"nome": "BRENDA", "genero": "F", "frequencia": 1000000}, + {"nome": "AMANDA", "genero": "F", "frequencia": 960000}, + {"nome": "STEPHANIE", "genero": "F", "frequencia": 920000}, + {"nome": "MELISSA", "genero": "F", "frequencia": 880000}, + {"nome": "DEBORAH", "genero": "F", "frequencia": 840000}, + {"nome": "RACHEL", "genero": "F", "frequencia": 800000}, + {"nome": "LAURA", "genero": "F", "frequencia": 760000}, + {"nome": "SHARON", "genero": "F", "frequencia": 720000}, + {"nome": "CYNTHIA", "genero": "F", "frequencia": 680000}, + {"nome": "KATHLEEN", "genero": "F", "frequencia": 660000}, + {"nome": "CHRISTINA", "genero": "F", "frequencia": 640000}, + {"nome": "VIRGINIA", "genero": "F", "frequencia": 620000}, + {"nome": "KATHERINE", "genero": "F", "frequencia": 600000}, + {"nome": "REBECCA", "genero": "F", "frequencia": 580000}, + {"nome": "JUDITH", "genero": "F", "frequencia": 560000}, + {"nome": "KELLY", "genero": "F", "frequencia": 540000}, + {"nome": "CHRISTINE", "genero": "F", "frequencia": 520000}, + {"nome": "DEBRA", "genero": "F", "frequencia": 500000}, + {"nome": "JOAN", "genero": "F", "frequencia": 480000}, + {"nome": "MARTHA", "genero": "F", "frequencia": 460000}, + {"nome": "EMMA", "genero": "F", "frequencia": 440000}, + {"nome": "OLIVIA", "genero": "F", "frequencia": 420000}, + {"nome": "MADISON", "genero": "F", "frequencia": 400000}, + {"nome": "ISABELLA", "genero": "F", "frequencia": 390000}, + {"nome": "SOPHIA", "genero": "F", "frequencia": 380000}, + {"nome": "MIA", "genero": "F", "frequencia": 370000}, + {"nome": "CHARLOTTE", "genero": "F", "frequencia": 360000}, + {"nome": "ABIGAIL", "genero": "F", "frequencia": 350000}, + {"nome": "VICTORIA", "genero": "F", "frequencia": 340000}, + {"nome": "GRACE", "genero": "F", "frequencia": 330000}, + {"nome": "SAMANTHA", "genero": "F", "frequencia": 320000}, + {"nome": "HANNAH", "genero": "F", "frequencia": 310000}, + {"nome": "EVELYN", "genero": "F", "frequencia": 300000}, + {"nome": "NICOLE", "genero": "F", "frequencia": 290000}, + {"nome": "DONNA", "genero": "F", "frequencia": 280000}, + {"nome": "CAROL", "genero": "F", "frequencia": 270000}, + {"nome": "RUTH", "genero": "F", "frequencia": 260000}, + {"nome": "DIANA", "genero": "F", "frequencia": 250000}, + {"nome": "ALICE", "genero": "F", "frequencia": 240000}, + {"nome": "JULIE", "genero": "F", "frequencia": 230000}, + {"nome": "HEATHER", "genero": "F", "frequencia": 220000}, + {"nome": "TERESA", "genero": "F", "frequencia": 210000}, + {"nome": "GLORIA", "genero": "F", "frequencia": 200000}, + {"nome": "CAROLYN", "genero": "F", "frequencia": 190000}, + {"nome": "JANET", "genero": "F", "frequencia": 180000}, + {"nome": "CHERYL", "genero": "F", "frequencia": 170000}, + {"nome": "FRANCES", "genero": "F", "frequencia": 160000}, + {"nome": "AMBER", "genero": "F", "frequencia": 150000}, + {"nome": "MARIE", "genero": "F", "frequencia": 140000}, + {"nome": "JACQUELINE", "genero": "F", "frequencia": 130000}, + {"nome": "ROSE", "genero": "F", "frequencia": 120000}, + {"nome": "WANDA", "genero": "F", "frequencia": 110000}, + {"nome": "JULIA", "genero": "F", "frequencia": 105000}, + {"nome": "TIFFANY", "genero": "F", "frequencia": 100000}, + {"nome": "NATALIE", "genero": "F", "frequencia": 96000}, + {"nome": "BEVERLY", "genero": "F", "frequencia": 92000}, + {"nome": "DENISE", "genero": "F", "frequencia": 88000}, + {"nome": "THERESA", "genero": "F", "frequencia": 84000}, + {"nome": "DANIELLE", "genero": "F", "frequencia": 80000}, + {"nome": "MARILYN", "genero": "F", "frequencia": 76000}, + {"nome": "LAUREN", "genero": "F", "frequencia": 72000}, + {"nome": "BRITTANY", "genero": "F", "frequencia": 68000}, + {"nome": "CHLOE", "genero": "F", "frequencia": 64000}, + {"nome": "ELEANOR", "genero": "F", "frequencia": 60000}, + {"nome": "CLAIRE", "genero": "F", "frequencia": 56000}, + {"nome": "RUBY", "genero": "F", "frequencia": 52000}, + {"nome": "ELLIE", "genero": "F", "frequencia": 48000}, + {"nome": "NORA", "genero": "F", "frequencia": 44000}, + {"nome": "HAZEL", "genero": "F", "frequencia": 40000}, + {"nome": "VIOLET", "genero": "F", "frequencia": 36000}, + {"nome": "AURORA", "genero": "F", "frequencia": 32000}, + {"nome": "ZOE", "genero": "F", "frequencia": 28000}, + {"nome": "PENELOPE", "genero": "F", "frequencia": 24000}, + {"nome": "LILLIAN", "genero": "F", "frequencia": 20000}, + {"nome": "ADDISON", "genero": "F", "frequencia": 18000}, + {"nome": "AUBREY", "genero": "F", "frequencia": 16000}, + {"nome": "ELIANA", "genero": "F", "frequencia": 14000}, + {"nome": "LAYLA", "genero": "F", "frequencia": 12000}, + {"nome": "SCARLETT", "genero": "F", "frequencia": 10000} +] diff --git a/src/Nalu.Jobs/Data/names_curated_es.json b/src/Nalu.Jobs/Data/names_curated_es.json new file mode 100644 index 0000000..d3e99df --- /dev/null +++ b/src/Nalu.Jobs/Data/names_curated_es.json @@ -0,0 +1,169 @@ +[ + {"nome": "JOSÉ", "genero": "M", "frequencia": 5000000}, + {"nome": "JOSE", "genero": "M", "frequencia": 5000000}, + {"nome": "JUAN", "genero": "M", "frequencia": 4500000}, + {"nome": "CARLOS", "genero": "M", "frequencia": 4200000}, + {"nome": "MIGUEL", "genero": "M", "frequencia": 3800000}, + {"nome": "ANTONIO", "genero": "M", "frequencia": 3600000}, + {"nome": "LUIS", "genero": "M", "frequencia": 3400000}, + {"nome": "FRANCISCO", "genero": "M", "frequencia": 3200000}, + {"nome": "MANUEL", "genero": "M", "frequencia": 3000000}, + {"nome": "ALEJANDRO", "genero": "M", "frequencia": 2800000}, + {"nome": "PEDRO", "genero": "M", "frequencia": 2600000}, + {"nome": "JESÚS", "genero": "M", "frequencia": 2400000}, + {"nome": "JESUS", "genero": "M", "frequencia": 2400000}, + {"nome": "JAVIER", "genero": "M", "frequencia": 2200000}, + {"nome": "FERNANDO", "genero": "M", "frequencia": 2000000}, + {"nome": "RAFAEL", "genero": "M", "frequencia": 1900000}, + {"nome": "JORGE", "genero": "M", "frequencia": 1800000}, + {"nome": "SERGIO", "genero": "M", "frequencia": 1700000}, + {"nome": "PABLO", "genero": "M", "frequencia": 1600000}, + {"nome": "ALBERTO", "genero": "M", "frequencia": 1500000}, + {"nome": "GUSTAVO", "genero": "M", "frequencia": 1400000}, + {"nome": "RODRIGO", "genero": "M", "frequencia": 1300000}, + {"nome": "EDUARDO", "genero": "M", "frequencia": 1200000}, + {"nome": "ERNESTO", "genero": "M", "frequencia": 1100000}, + {"nome": "ROBERTO", "genero": "M", "frequencia": 1050000}, + {"nome": "ENRIQUE", "genero": "M", "frequencia": 1000000}, + {"nome": "ARTURO", "genero": "M", "frequencia": 950000}, + {"nome": "DIEGO", "genero": "M", "frequencia": 900000}, + {"nome": "HÉCTOR", "genero": "M", "frequencia": 870000}, + {"nome": "HECTOR", "genero": "M", "frequencia": 870000}, + {"nome": "MARIO", "genero": "M", "frequencia": 840000}, + {"nome": "IGNACIO", "genero": "M", "frequencia": 810000}, + {"nome": "JULIO", "genero": "M", "frequencia": 780000}, + {"nome": "GABRIEL", "genero": "M", "frequencia": 750000}, + {"nome": "ÓSCAR", "genero": "M", "frequencia": 720000}, + {"nome": "OSCAR", "genero": "M", "frequencia": 720000}, + {"nome": "FELIPE", "genero": "M", "frequencia": 700000}, + {"nome": "IVÁN", "genero": "M", "frequencia": 680000}, + {"nome": "IVAN", "genero": "M", "frequencia": 680000}, + {"nome": "SALVADOR", "genero": "M", "frequencia": 660000}, + {"nome": "MARCOS", "genero": "M", "frequencia": 640000}, + {"nome": "SANTIAGO", "genero": "M", "frequencia": 620000}, + {"nome": "VÍCTOR", "genero": "M", "frequencia": 600000}, + {"nome": "VICTOR", "genero": "M", "frequencia": 600000}, + {"nome": "NICOLÁS", "genero": "M", "frequencia": 580000}, + {"nome": "NICOLAS", "genero": "M", "frequencia": 580000}, + {"nome": "CÉSAR", "genero": "M", "frequencia": 560000}, + {"nome": "CESAR", "genero": "M", "frequencia": 560000}, + {"nome": "MAURICIO", "genero": "M", "frequencia": 540000}, + {"nome": "RUBÉN", "genero": "M", "frequencia": 520000}, + {"nome": "RUBEN", "genero": "M", "frequencia": 520000}, + {"nome": "HUGO", "genero": "M", "frequencia": 500000}, + {"nome": "RAÚL", "genero": "M", "frequencia": 480000}, + {"nome": "RAUL", "genero": "M", "frequencia": 480000}, + {"nome": "JOAQUÍN", "genero": "M", "frequencia": 460000}, + {"nome": "JOAQUIN", "genero": "M", "frequencia": 460000}, + {"nome": "ALFREDO", "genero": "M", "frequencia": 440000}, + {"nome": "ADRIÁN", "genero": "M", "frequencia": 420000}, + {"nome": "ADRIAN", "genero": "M", "frequencia": 420000}, + {"nome": "ANDRÉS", "genero": "M", "frequencia": 400000}, + {"nome": "ANDRES", "genero": "M", "frequencia": 400000}, + {"nome": "SEBASTIÁN", "genero": "M", "frequencia": 390000}, + {"nome": "SEBASTIAN", "genero": "M", "frequencia": 390000}, + {"nome": "MATEO", "genero": "M", "frequencia": 380000}, + {"nome": "CAMILO", "genero": "M", "frequencia": 370000}, + {"nome": "MARTIN", "genero": "M", "frequencia": 360000}, + {"nome": "MARTÍN", "genero": "M", "frequencia": 360000}, + {"nome": "EMILIO", "genero": "M", "frequencia": 350000}, + {"nome": "RAMÓN", "genero": "M", "frequencia": 340000}, + {"nome": "RAMON", "genero": "M", "frequencia": 340000}, + {"nome": "SANTIAGO", "genero": "M", "frequencia": 330000}, + {"nome": "TOMÁS", "genero": "M", "frequencia": 320000}, + {"nome": "TOMAS", "genero": "M", "frequencia": 320000}, + {"nome": "ESTEBAN", "genero": "M", "frequencia": 310000}, + {"nome": "CRISTIAN", "genero": "M", "frequencia": 300000}, + {"nome": "PACO", "genero": "M", "frequencia": 100000}, + {"nome": "NACHO", "genero": "M", "frequencia": 90000}, + {"nome": "PEPE", "genero": "M", "frequencia": 80000}, + {"nome": "FRAN", "genero": "M", "frequencia": 70000}, + {"nome": "SANTI", "genero": "M", "frequencia": 60000}, + {"nome": "ALEX", "genero": "N", "frequencia": 200000}, + {"nome": "DARÍO", "genero": "M", "frequencia": 150000}, + {"nome": "DARIO", "genero": "M", "frequencia": 150000}, + {"nome": "XAVIER", "genero": "M", "frequencia": 140000}, + {"nome": "IKER", "genero": "M", "frequencia": 130000}, + {"nome": "POL", "genero": "M", "frequencia": 90000}, + {"nome": "MARC", "genero": "M", "frequencia": 180000}, + {"nome": "PAU", "genero": "M", "frequencia": 110000}, + {"nome": "MARÍA", "genero": "F", "frequencia": 5500000}, + {"nome": "MARIA", "genero": "F", "frequencia": 5500000}, + {"nome": "ANA", "genero": "F", "frequencia": 4000000}, + {"nome": "SOFÍA", "genero": "F", "frequencia": 3500000}, + {"nome": "SOFIA", "genero": "F", "frequencia": 3500000}, + {"nome": "VALENTINA", "genero": "F", "frequencia": 3200000}, + {"nome": "CAMILA", "genero": "F", "frequencia": 3000000}, + {"nome": "LUCÍA", "genero": "F", "frequencia": 2800000}, + {"nome": "LUCIA", "genero": "F", "frequencia": 2800000}, + {"nome": "MARIANA", "genero": "F", "frequencia": 2600000}, + {"nome": "PAULA", "genero": "F", "frequencia": 2400000}, + {"nome": "ISABEL", "genero": "F", "frequencia": 2200000}, + {"nome": "CARMEN", "genero": "F", "frequencia": 2000000}, + {"nome": "ALEJANDRA", "genero": "F", "frequencia": 1900000}, + {"nome": "DIANA", "genero": "F", "frequencia": 1800000}, + {"nome": "ROSA", "genero": "F", "frequencia": 1700000}, + {"nome": "LAURA", "genero": "F", "frequencia": 1600000}, + {"nome": "ELENA", "genero": "F", "frequencia": 1500000}, + {"nome": "CARLA", "genero": "F", "frequencia": 1400000}, + {"nome": "FERNANDA", "genero": "F", "frequencia": 1300000}, + {"nome": "TERESA", "genero": "F", "frequencia": 1200000}, + {"nome": "CLAUDIA", "genero": "F", "frequencia": 1100000}, + {"nome": "ADRIANA", "genero": "F", "frequencia": 1050000}, + {"nome": "VERÓNICA", "genero": "F", "frequencia": 1000000}, + {"nome": "VERONICA", "genero": "F", "frequencia": 1000000}, + {"nome": "DANIELA", "genero": "F", "frequencia": 960000}, + {"nome": "GABRIELA", "genero": "F", "frequencia": 920000}, + {"nome": "PATRICIA", "genero": "F", "frequencia": 880000}, + {"nome": "NATALIA", "genero": "F", "frequencia": 840000}, + {"nome": "MÓNICA", "genero": "F", "frequencia": 800000}, + {"nome": "MONICA", "genero": "F", "frequencia": 800000}, + {"nome": "SANDRA", "genero": "F", "frequencia": 760000}, + {"nome": "PILAR", "genero": "F", "frequencia": 720000}, + {"nome": "LORENA", "genero": "F", "frequencia": 680000}, + {"nome": "CAROLINA", "genero": "F", "frequencia": 640000}, + {"nome": "PAOLA", "genero": "F", "frequencia": 600000}, + {"nome": "YOLANDA", "genero": "F", "frequencia": 560000}, + {"nome": "ANDREA", "genero": "F", "frequencia": 540000}, + {"nome": "ÁNGELA", "genero": "F", "frequencia": 520000}, + {"nome": "ANGELA", "genero": "F", "frequencia": 520000}, + {"nome": "BEATRIZ", "genero": "F", "frequencia": 500000}, + {"nome": "DOLORES", "genero": "F", "frequencia": 480000}, + {"nome": "GLORIA", "genero": "F", "frequencia": 460000}, + {"nome": "MARISOL", "genero": "F", "frequencia": 440000}, + {"nome": "MIRIAM", "genero": "F", "frequencia": 420000}, + {"nome": "NADIA", "genero": "F", "frequencia": 400000}, + {"nome": "SONIA", "genero": "F", "frequencia": 380000}, + {"nome": "SUSANA", "genero": "F", "frequencia": 360000}, + {"nome": "VANESSA", "genero": "F", "frequencia": 340000}, + {"nome": "REBECA", "genero": "F", "frequencia": 320000}, + {"nome": "INÉS", "genero": "F", "frequencia": 300000}, + {"nome": "INES", "genero": "F", "frequencia": 300000}, + {"nome": "ALICIA", "genero": "F", "frequencia": 290000}, + {"nome": "IRENE", "genero": "F", "frequencia": 280000}, + {"nome": "ROCÍO", "genero": "F", "frequencia": 270000}, + {"nome": "ROCIO", "genero": "F", "frequencia": 270000}, + {"nome": "LOLA", "genero": "F", "frequencia": 260000}, + {"nome": "MARTA", "genero": "F", "frequencia": 250000}, + {"nome": "SARA", "genero": "F", "frequencia": 240000}, + {"nome": "VICTORIA", "genero": "F", "frequencia": 230000}, + {"nome": "ALBA", "genero": "F", "frequencia": 220000}, + {"nome": "NEREA", "genero": "F", "frequencia": 210000}, + {"nome": "JULIA", "genero": "F", "frequencia": 200000}, + {"nome": "EMMA", "genero": "F", "frequencia": 190000}, + {"nome": "VALERIA", "genero": "F", "frequencia": 180000}, + {"nome": "LUNA", "genero": "F", "frequencia": 170000}, + {"nome": "MARTINA", "genero": "F", "frequencia": 160000}, + {"nome": "ISABELLA", "genero": "F", "frequencia": 150000}, + {"nome": "PALOMA", "genero": "F", "frequencia": 140000}, + {"nome": "CONSUELO", "genero": "F", "frequencia": 130000}, + {"nome": "ESPERANZA", "genero": "F", "frequencia": 120000}, + {"nome": "AMPARO", "genero": "F", "frequencia": 110000}, + {"nome": "MERCEDES", "genero": "F", "frequencia": 100000}, + {"nome": "CONCEPCIÓN", "genero": "F", "frequencia": 90000}, + {"nome": "CONCEPCION", "genero": "F", "frequencia": 90000}, + {"nome": "LUPE", "genero": "F", "frequencia": 80000}, + {"nome": "PILI", "genero": "F", "frequencia": 60000}, + {"nome": "MARI", "genero": "F", "frequencia": 50000}, + {"nome": "CRIS", "genero": "F", "frequencia": 40000} +] diff --git a/src/Nalu.Jobs/INameImporterJob.cs b/src/Nalu.Jobs/INameImporterJob.cs new file mode 100644 index 0000000..acb574c --- /dev/null +++ b/src/Nalu.Jobs/INameImporterJob.cs @@ -0,0 +1,10 @@ +namespace Nalu.Jobs; + +public interface INameImporterJob +{ + /// + /// Imports Brazilian names from IBGE into MongoDB. + /// + /// When true, reimports even if a successful run exists today. + Task ExecuteAsync(bool forceFull = false); +} diff --git a/src/Nalu.Jobs/IbgeClient.cs b/src/Nalu.Jobs/IbgeClient.cs new file mode 100644 index 0000000..1a609a1 --- /dev/null +++ b/src/Nalu.Jobs/IbgeClient.cs @@ -0,0 +1,73 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Nalu.Jobs; + +public static class IbgeClient +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true + }; + + private static readonly (string Url, string? Genero)[] Endpoints = + [ + ("https://servicodados.ibge.gov.br/api/v2/censos/nomes/ranking/", null), + ("https://servicodados.ibge.gov.br/api/v2/censos/nomes/ranking/?sexo=M", "M"), + ("https://servicodados.ibge.gov.br/api/v2/censos/nomes/ranking/?sexo=F", "F"), + ]; + + /// + /// Fetches names from the 3 IBGE ranking endpoints and aggregates into a single map. + /// M+F overlap → genero becomes "N". + /// + public static async Task> FetchNamesAsync( + HttpClient http, + ILogger logger, + CancellationToken ct) + { + var aggregated = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (url, generoOverride) in Endpoints) + { + logger.LogInformation("GET {Url}", url); + + var response = await http.GetAsync(url, ct); + response.EnsureSuccessStatusCode(); + + var raw = await response.Content.ReadAsStringAsync(ct); + var groups = JsonSerializer.Deserialize(raw, JsonOpts) ?? []; + + foreach (var group in groups) + { + var groupGenero = generoOverride + ?? group.Sexo?.ToUpperInvariant() switch { "M" => "M", "F" => "F", _ => "N" }; + + foreach (var entry in group.Res) + { + if (string.IsNullOrWhiteSpace(entry.Nome)) continue; + + var nomeUpper = entry.Nome.Trim().ToUpperInvariant(); + + if (aggregated.TryGetValue(nomeUpper, out var existing)) + { + aggregated[nomeUpper] = existing with + { + Frequencia = Math.Max(existing.Frequencia, entry.Frequencia), + Genero = existing.Genero == groupGenero ? groupGenero : "N" + }; + } + else + { + aggregated[nomeUpper] = new AggregatedName(nomeUpper, entry.Frequencia, groupGenero); + } + } + + logger.LogDebug(" → {Count} entries (total unique: {Total})", group.Res.Length, aggregated.Count); + } + } + + logger.LogInformation("IBGE fetch complete — {Count} unique names", aggregated.Count); + return aggregated; + } +} diff --git a/src/Nalu.Jobs/Models.cs b/src/Nalu.Jobs/Models.cs new file mode 100644 index 0000000..cd99e5d --- /dev/null +++ b/src/Nalu.Jobs/Models.cs @@ -0,0 +1,98 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using System.Text.Json.Serialization; + +namespace Nalu.Jobs; + +// ── IBGE API response ────────────────────────────────────────────────────────── + +/// +/// Top-level element returned by /v2/censos/nomes/ranking/ (optionally ?sexo=M|F). +/// Shape: [{ "localidade": "BR", "sexo": "M"|"F"|null, "res": [...] }] +/// +public record IbgeRankingGroup +{ + [JsonPropertyName("localidade")] + public string Localidade { get; init; } = ""; + + /// null = overall, "M" = male, "F" = female + [JsonPropertyName("sexo")] + public string? Sexo { get; init; } + + [JsonPropertyName("res")] + public IbgeNameEntry[] Res { get; init; } = []; +} + +public record IbgeNameEntry +{ + [JsonPropertyName("nome")] + public string Nome { get; init; } = ""; + + [JsonPropertyName("frequencia")] + public long Frequencia { get; init; } + + [JsonPropertyName("ranking")] + public int Ranking { get; init; } +} + +// ── In-memory aggregation ────────────────────────────────────────────────────── + +public record AggregatedName(string Nome, long Frequencia, string Genero); + +// ── MongoDB documents ────────────────────────────────────────────────────────── + +public class NomeBr +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("nome")] + public string Nome { get; set; } = ""; + + [BsonElement("nome_display")] + public string NomeDisplay { get; set; } = ""; + + [BsonElement("tipo")] + public string Tipo { get; set; } = "primeiro_nome"; + + [BsonElement("frequencia")] + public long Frequencia { get; set; } + + [BsonElement("genero")] + public string Genero { get; set; } = "N"; + + [BsonElement("fonte")] + public string Fonte { get; set; } = "ibge_censo"; + + [BsonElement("ultima_atualizacao")] + public DateTime UltimaAtualizacao { get; set; } +} + +public class JobRun +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("job")] + public string Job { get; set; } = "NaluNameImporter"; + + [BsonElement("executado_em")] + public DateTime ExecutadoEm { get; set; } + + [BsonElement("total_processados")] + public int TotalProcessados { get; set; } + + [BsonElement("total_inseridos")] + public int TotalInseridos { get; set; } + + [BsonElement("total_atualizados")] + public int TotalAtualizados { get; set; } + + [BsonElement("status")] + public string Status { get; set; } = "sucesso"; + + [BsonElement("erro")] + public string? Erro { get; set; } +} diff --git a/src/Nalu.Jobs/Nalu.Jobs.csproj b/src/Nalu.Jobs/Nalu.Jobs.csproj new file mode 100644 index 0000000..7892d0c --- /dev/null +++ b/src/Nalu.Jobs/Nalu.Jobs.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + Nalu.Jobs + Nalu.Jobs + + + + + + + + + + + + + + + diff --git a/src/Nalu.Jobs/NaluNameImporterJob.cs b/src/Nalu.Jobs/NaluNameImporterJob.cs new file mode 100644 index 0000000..e640962 --- /dev/null +++ b/src/Nalu.Jobs/NaluNameImporterJob.cs @@ -0,0 +1,117 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Nalu.Jobs; + +public class NaluNameImporterJob : INameImporterJob +{ + private readonly IMongoClient _mongoClient; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public NaluNameImporterJob( + IMongoClient mongoClient, + IConfiguration config, + ILogger logger) + { + _mongoClient = mongoClient; + _config = config; + _logger = logger; + } + + public async Task ExecuteAsync(bool forceFull = false) + { + _logger.LogInformation("NaluNameImporter starting (forceFull={ForceFull})", forceFull); + + var db = ResolveDatabase(); + var repo = new NameRepository(db); + + var startedAt = DateTime.UtcNow; + int totalProcessed = 0, totalInserted = 0, totalUpdated = 0; + string? errorMessage = null; + + try + { + await repo.EnsureIndexesAsync(CancellationToken.None); + + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + http.DefaultRequestHeaders.UserAgent.ParseAdd("NaluNameImporter/1.0"); + + var names = await IbgeClient.FetchNamesAsync(http, _logger, CancellationToken.None); + + if (names.Count == 0) + throw new InvalidOperationException("IBGE returned 0 names."); + + _logger.LogInformation("Starting upsert of {Count} names", names.Count); + + var now = DateTime.UtcNow; + + foreach (var kv in names) + { + var wasInserted = await repo.UpsertAsync(kv.Value, now, CancellationToken.None); + + totalProcessed++; + if (wasInserted) totalInserted++; + else totalUpdated++; + + if (totalProcessed % 500 == 0 || totalProcessed == names.Count) + { + _logger.LogInformation( + "[{Processed}/{Total}] inserted={Inserted} updated={Updated}", + totalProcessed, names.Count, totalInserted, totalUpdated); + } + } + + _logger.LogInformation( + "NaluNameImporter done — processed={P} inserted={I} updated={U}", + totalProcessed, totalInserted, totalUpdated); + + // ── Step 2: import curated list ──────────────────────────────────── + _logger.LogInformation("Step 2: importing curated names list..."); + await CuratedNamesImporter.ImportAsync(db, _logger, CancellationToken.None); + } + catch (Exception ex) + { + errorMessage = ex.Message; + _logger.LogError(ex, "NaluNameImporter failed: {Message}", ex.Message); + throw; // re-throw so Hangfire marks job as failed and retries + } + finally + { + try + { + await repo.SaveJobRunAsync(new JobRun + { + Job = "NaluNameImporter", + ExecutadoEm = startedAt, + TotalProcessados = totalProcessed, + TotalInseridos = totalInserted, + TotalAtualizados = totalUpdated, + Status = errorMessage is null ? "sucesso" : "erro", + Erro = errorMessage + }, CancellationToken.None); + } + catch (Exception logEx) + { + _logger.LogWarning(logEx, "Failed to save job_run log"); + } + } + } + + private IMongoDatabase ResolveDatabase() + { + // Try the ASP.NET Core connection string first, then env-var style + var connStr = _config.GetConnectionString("MongoDB") + ?? _config["MONGO_CONNECTION_STRING"] + ?? "mongodb://localhost:27017"; + + // Database name: from MongoUrl path segment, then explicit config, then default + var mongoUrl = MongoDB.Driver.MongoUrl.Create(connStr); + var dbName = (!string.IsNullOrWhiteSpace(mongoUrl.DatabaseName) ? mongoUrl.DatabaseName : null) + ?? _config["MONGO_DATABASE"] + ?? "nalu"; + + return _mongoClient.GetDatabase(dbName); + } +} diff --git a/src/Nalu.Jobs/NameRepository.cs b/src/Nalu.Jobs/NameRepository.cs new file mode 100644 index 0000000..1c0c2dc --- /dev/null +++ b/src/Nalu.Jobs/NameRepository.cs @@ -0,0 +1,71 @@ +using MongoDB.Driver; + +namespace Nalu.Jobs; + +public class NameRepository +{ + private readonly IMongoCollection _nomes; + private readonly IMongoCollection _jobRuns; + + public NameRepository(IMongoDatabase db) + { + _nomes = db.GetCollection("nomes_br"); + _jobRuns = db.GetCollection("job_runs"); + } + + public async Task EnsureIndexesAsync(CancellationToken ct) + { + var model = new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.Nome), + new CreateIndexOptions { Unique = true, Name = "idx_nome_unique" }); + + await _nomes.Indexes.CreateOneAsync(model, cancellationToken: ct); + } + + /// + /// Upserts a single name. + /// New doc: inserts all fields. Existing: increments frequencia, merges genero, updates timestamp. + /// Returns true if inserted (new), false if updated. + /// + public async Task UpsertAsync(AggregatedName name, DateTime now, CancellationToken ct) + { + var nomeDisplay = ToTitleCase(name.Nome); + var filter = Builders.Filter.Eq(x => x.Nome, name.Nome); + var existing = await _nomes.Find(filter).FirstOrDefaultAsync(ct); + + UpdateDefinition update; + + if (existing is null) + { + update = Builders.Update + .SetOnInsert(x => x.Nome, name.Nome) + .SetOnInsert(x => x.NomeDisplay, nomeDisplay) + .SetOnInsert(x => x.Tipo, "primeiro_nome") + .SetOnInsert(x => x.Genero, name.Genero) + .SetOnInsert(x => x.Fonte, "ibge_censo") + .Inc(x => x.Frequencia, name.Frequencia) + .Set(x => x.UltimaAtualizacao, now); + } + else + { + var mergedGenero = existing.Genero == name.Genero ? existing.Genero : "N"; + update = Builders.Update + .Inc(x => x.Frequencia, name.Frequencia) + .Set(x => x.Genero, mergedGenero) + .Set(x => x.UltimaAtualizacao, now); + } + + var result = await _nomes.UpdateOneAsync(filter, update, + new UpdateOptions { IsUpsert = true }, ct); + + return result.UpsertedId is not null; + } + + public async Task SaveJobRunAsync(JobRun run, CancellationToken ct) => + await _jobRuns.InsertOneAsync(run, null, ct); + + private static string ToTitleCase(string upper) => + string.Join(" ", upper.Split(' ') + .Select(w => w.Length == 0 ? w : + char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant())); +} diff --git a/src/Nalu.NameImporter/Nalu.NameImporter.csproj b/src/Nalu.NameImporter/Nalu.NameImporter.csproj new file mode 100644 index 0000000..3ad0b6d --- /dev/null +++ b/src/Nalu.NameImporter/Nalu.NameImporter.csproj @@ -0,0 +1,32 @@ + + + + Exe + net8.0 + enable + enable + Nalu.NameImporter + Nalu.NameImporter + enable + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/src/Nalu.NameImporter/Program.cs b/src/Nalu.NameImporter/Program.cs new file mode 100644 index 0000000..88a1712 --- /dev/null +++ b/src/Nalu.NameImporter/Program.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; +using Nalu.Jobs; + +// ── Graceful shutdown ────────────────────────────────────────────────────────── +using var cts = new CancellationTokenSource(); +Console.CancelKeyPress += (_, e) => +{ + e.Cancel = true; + Console.WriteLine("Shutdown requested — finishing gracefully..."); + cts.Cancel(); +}; + +// ── Configuration ────────────────────────────────────────────────────────────── +var config = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .Build(); + +bool forceFull = args.Contains("--force-full"); + +// ── DI ────────────────────────────────────────────────────────────────────────── +var connStr = config["MONGO_CONNECTION_STRING"] ?? "mongodb://localhost:27017"; + +var services = new ServiceCollection(); +services.AddSingleton(config); +services.AddSingleton(_ => new MongoClient(connStr)); +services.AddSingleton(); +services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Information)); + +await using var provider = services.BuildServiceProvider(); + +// ── Run ──────────────────────────────────────────────────────────────────────── +Console.WriteLine("═══════════════════════════════════════════"); +Console.WriteLine(" NALU Name Importer — IBGE Census Names"); +Console.WriteLine("═══════════════════════════════════════════"); +Console.WriteLine($" Force : {forceFull}"); +Console.WriteLine(); + +try +{ + var job = provider.GetRequiredService(); + await job.ExecuteAsync(forceFull); + return 0; +} +catch (OperationCanceledException) +{ + Console.Error.WriteLine("Cancelled."); + return 1; +} +catch (Exception ex) +{ + Console.Error.WriteLine($"ERROR: {ex.Message}"); + return 1; +} diff --git a/src/Nalu.NameImporter/appsettings.json b/src/Nalu.NameImporter/appsettings.json new file mode 100644 index 0000000..12f99d4 --- /dev/null +++ b/src/Nalu.NameImporter/appsettings.json @@ -0,0 +1,4 @@ +{ + "MONGO_CONNECTION_STRING": "mongodb://localhost:27017", + "MONGO_DATABASE": "nalu" +} diff --git a/src/Nalu.Web/Endpoints/ExtractEndpoints.cs b/src/Nalu.Web/Endpoints/ExtractEndpoints.cs index 7b57079..2cad98b 100644 --- a/src/Nalu.Web/Endpoints/ExtractEndpoints.cs +++ b/src/Nalu.Web/Endpoints/ExtractEndpoints.cs @@ -118,15 +118,29 @@ public static class ExtractEndpoints group.MapPost("/name", async (HttpContext ctx, [FromBody] ExtractionRequest req, ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_name", ctx, ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_name", req, ct)); + }) + .WithName("ExtractName") + .WithSummary("Extrai nome ou apelido") + .WithDescription("Extrai o nome ou apelido do usuário. Aceita primeiro nome sem sobrenome. Custa 3 créditos.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/full-name", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => { var cr = await credits.TryConsumeAsync(ctx.User, "validate_full_name", ctx, ct); credits.ApplyHeaders(ctx, cr); if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); return Results.Ok(await pipeline.ExecuteAsync("validate_full_name", req, ct)); }) - .WithName("ExtractName") - .WithSummary("Extrai nome completo") - .WithDescription("Detecta e valida o nome completo do usuário a partir do diálogo. Custa 2 créditos.") + .WithName("ExtractFullName") + .WithSummary("Extrai nome completo com sobrenome") + .WithDescription("Extrai o nome completo do usuário. Exige sobrenome — retorna certain: false se apenas o primeiro nome for informado. Custa 3 créditos.") .Produces().ProducesProblem(429).WithOpenApi(); group.MapPost("/yes-no", async (HttpContext ctx, diff --git a/src/Nalu.Web/Endpoints/PlaygroundEndpoints.cs b/src/Nalu.Web/Endpoints/PlaygroundEndpoints.cs index 376a8af..889a496 100644 --- a/src/Nalu.Web/Endpoints/PlaygroundEndpoints.cs +++ b/src/Nalu.Web/Endpoints/PlaygroundEndpoints.cs @@ -1,9 +1,7 @@ -using System.Collections.Concurrent; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using Nalu.Web.Models; using Nalu.Web.Services; -using Nalu.Web.Services.LlmRouter; namespace Nalu.Web.Endpoints; @@ -13,6 +11,7 @@ public static class PlaygroundEndpoints public static void MapPlaygroundEndpoints(this WebApplication app) { + // Extraction validators — ExtractionRequest body app.MapPost("/v1/playground/extract/{validator}", async ( string validator, HttpContext ctx, @@ -21,25 +20,10 @@ public static class PlaygroundEndpoints IMemoryCache cache, CancellationToken ct) => { - // IP-based rate limit: 10 calls/IP/day - var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - var cacheKey = $"pg:{ip}:{DateTime.UtcNow:yyyyMMdd}"; - - var count = cache.GetOrCreate(cacheKey, e => - { - e.AbsoluteExpiration = DateTime.UtcNow.Date.AddDays(1); - return 0; - }); - - if (count >= DailyLimit) + if (!CheckRateLimit(ctx, cache, out var remaining)) return Results.Json(new { error = "Limite diário de 10 chamadas atingido. Crie uma conta para 3.000 créditos grátis." }, statusCode: 429); - cache.Set(cacheKey, count + 1, new MemoryCacheEntryOptions - { - AbsoluteExpiration = DateTime.UtcNow.Date.AddDays(1) - }); - - ctx.Response.Headers["X-Playground-Calls-Remaining"] = (DailyLimit - count - 1).ToString(); + ctx.Response.Headers["X-Playground-Calls-Remaining"] = remaining.ToString(); try { @@ -54,5 +38,58 @@ public static class PlaygroundEndpoints .AllowAnonymous() .WithTags("Playground") .WithSummary("Playground — sem auth, 10 chamadas/IP/dia"); + + // validate_reply — ReplyRequest body (different schema) + app.MapPost("/v1/playground/extract/reply", async ( + HttpContext ctx, + [FromBody] ReplyRequest req, + ReplyService replyService, + IMemoryCache cache, + CancellationToken ct) => + { + if (!CheckRateLimit(ctx, cache, out var remaining)) + return Results.Json(new { error = "Limite diário de 10 chamadas atingido. Crie uma conta para 3.000 créditos grátis." }, statusCode: 429); + + ctx.Response.Headers["X-Playground-Calls-Remaining"] = remaining.ToString(); + + try + { + var result = await replyService.AnalyzeAsync(req, ct); + return Results.Ok(result); + } + catch (Exception ex) + { + return Results.Json(new { error = ex.Message }, statusCode: 503); + } + }) + .AllowAnonymous() + .WithTags("Playground") + .WithSummary("Playground validate_reply — sem auth, 10 chamadas/IP/dia"); + } + + private static bool CheckRateLimit(HttpContext ctx, IMemoryCache cache, out int remaining) + { + var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + var cacheKey = $"pg:{ip}:{DateTime.UtcNow:yyyyMMdd}"; + + var count = cache.GetOrCreate(cacheKey, e => + { + e.AbsoluteExpiration = DateTime.UtcNow.Date.AddDays(1); + return 0; + }); + + if (count >= DailyLimit) + { + remaining = 0; + return false; + } + + cache.Set(cacheKey, count + 1, new MemoryCacheEntryOptions + { + AbsoluteExpiration = DateTime.UtcNow.Date.AddDays(1) + }); + + remaining = DailyLimit - count - 1; + return true; } } diff --git a/src/Nalu.Web/Endpoints/StripeEndpoints.cs b/src/Nalu.Web/Endpoints/StripeEndpoints.cs new file mode 100644 index 0000000..258a36a --- /dev/null +++ b/src/Nalu.Web/Endpoints/StripeEndpoints.cs @@ -0,0 +1,241 @@ +using Nalu.Web.Data.Repositories; +using Stripe; +using Stripe.Checkout; +using NaluSubscription = Nalu.Web.Data.Models.Subscription; + +namespace Nalu.Web.Endpoints; + +public static class StripeEndpoints +{ + public static void MapStripeEndpoints(this WebApplication app) + { + app.MapPost("/webhooks/stripe", async ( + HttpContext ctx, + IConfiguration config, + WebhookEventRepository webhookEvents, + SubscriptionRepository subscriptions, + UserRepository users, + ILogger logger, + CancellationToken ct) => + { + var webhookSecret = config["Stripe:WebhookSecret"] ?? ""; + if (string.IsNullOrEmpty(webhookSecret)) + { + logger.LogError("Stripe:WebhookSecret not configured"); + return Results.StatusCode(500); + } + + string json; + using (var reader = new StreamReader(ctx.Request.Body)) + json = await reader.ReadToEndAsync(ct); + + var signature = ctx.Request.Headers["Stripe-Signature"].FirstOrDefault(); + if (string.IsNullOrEmpty(signature)) + return Results.BadRequest("Missing Stripe-Signature"); + + Event stripeEvent; + try + { + stripeEvent = EventUtility.ConstructEvent(json, signature, webhookSecret, + throwOnApiVersionMismatch: false); + } + catch (StripeException ex) + { + logger.LogWarning("Stripe signature validation failed: {Msg}", ex.Message); + return Results.BadRequest("Invalid signature"); + } + + // ── Idempotency check ───────────────────────────────────────────── + var isNew = await webhookEvents.TryInsertAsync(stripeEvent.Id, stripeEvent.Type, ct); + if (!isNew) + { + logger.LogInformation("Duplicate webhook {EventId} ({Type}) — skipped", stripeEvent.Id, stripeEvent.Type); + return Results.Ok(); + } + + logger.LogInformation("Processing webhook {EventId} {Type}", stripeEvent.Id, stripeEvent.Type); + + try + { + switch (stripeEvent.Type) + { + case "checkout.session.completed": + await HandleCheckoutCompleted(stripeEvent, subscriptions, users, logger, ct); + break; + + case "invoice.paid": + await HandleInvoicePaid(stripeEvent, subscriptions, users, logger, ct); + break; + + case "invoice.payment_failed": + await HandleInvoicePaymentFailed(stripeEvent, subscriptions, logger, ct); + break; + + case "customer.subscription.updated": + await HandleSubscriptionUpdated(stripeEvent, subscriptions, logger, ct); + break; + + case "customer.subscription.deleted": + await HandleSubscriptionDeleted(stripeEvent, subscriptions, users, logger, ct); + break; + + default: + logger.LogInformation("Unhandled event type: {Type}", stripeEvent.Type); + break; + } + } + catch (Exception ex) + { + // Return 200 to Stripe to avoid retries for permanent failures. + // Log and investigate manually. + logger.LogError(ex, "Error processing webhook {EventId} {Type}", stripeEvent.Id, stripeEvent.Type); + } + + return Results.Ok(); + }) + .WithName("StripeWebhook") + .ExcludeFromDescription(); // hide from OpenAPI + } + + // ── checkout.session.completed ──────────────────────────────────────────── + // Fires on first successful payment. Metadata contains user_id + plan. + private static async Task HandleCheckoutCompleted( + Event stripeEvent, SubscriptionRepository subscriptions, + UserRepository users, ILogger logger, CancellationToken ct) + { + if (stripeEvent.Data.Object is not Session session) return; + + var userId = session.Metadata?.GetValueOrDefault("user_id"); + var plan = session.Metadata?.GetValueOrDefault("plan"); + + if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(plan)) + { + logger.LogWarning("checkout.session.completed missing metadata user_id/plan — session {Id}", session.Id); + return; + } + + var stripeSubId = session.SubscriptionId; + var stripeCustomId = session.CustomerId; + + if (string.IsNullOrEmpty(stripeSubId)) + { + logger.LogWarning("checkout.session.completed has no SubscriptionId — session {Id}", session.Id); + return; + } + + // Upsert subscription record + var sub = new NaluSubscription + { + UserId = userId, + StripeSubscriptionId = stripeSubId, + StripeCustomerId = stripeCustomId ?? "", + Plan = plan, + Status = "active", + CurrentPeriodStart = session.Created, // approximate — will be corrected by invoice.paid + CurrentPeriodEnd = session.Created.AddMonths(1), + }; + await subscriptions.UpsertAsync(sub, ct); + + // Link Stripe customer to user + upgrade plan + if (!string.IsNullOrEmpty(stripeCustomId)) + await users.SetStripeCustomerAsync(userId, stripeCustomId, ct); + + await users.UpdatePlanAsync(userId, plan, ct); + + logger.LogInformation("Checkout completed: user {UserId} upgraded to {Plan}", userId, plan); + } + + // ── invoice.paid ────────────────────────────────────────────────────────── + // Fires on every successful billing cycle (including first payment). + // First-cycle invoice.paid fires after checkout.session.completed, + // so idempotency protects the user plan from being double-written. + private static async Task HandleInvoicePaid( + Event stripeEvent, SubscriptionRepository subscriptions, + UserRepository users, ILogger logger, CancellationToken ct) + { + if (stripeEvent.Data.Object is not Invoice invoice) return; + + var stripeSubId = invoice.SubscriptionId; + if (string.IsNullOrEmpty(stripeSubId)) return; + + var sub = await subscriptions.FindByStripeIdAsync(stripeSubId, ct); + if (sub == null) + { + logger.LogWarning("invoice.paid: no subscription found for {SubId}", stripeSubId); + return; + } + + sub.Status = "active"; + sub.CurrentPeriodStart = invoice.PeriodStart; + sub.CurrentPeriodEnd = invoice.PeriodEnd; + await subscriptions.UpsertAsync(sub, ct); + + // Re-apply plan in case it was downgraded by a failed payment + await users.UpdatePlanAsync(sub.UserId, sub.Plan, ct); + + logger.LogInformation("Invoice paid: subscription {SubId} renewed for user {UserId}", stripeSubId, sub.UserId); + } + + // ── invoice.payment_failed ──────────────────────────────────────────────── + // Stripe will retry automatically. Mark as past_due but keep plan active + // until customer.subscription.deleted fires (after all retries exhausted). + private static async Task HandleInvoicePaymentFailed( + Event stripeEvent, SubscriptionRepository subscriptions, + ILogger logger, CancellationToken ct) + { + if (stripeEvent.Data.Object is not Invoice invoice) return; + + var stripeSubId = invoice.SubscriptionId; + if (string.IsNullOrEmpty(stripeSubId)) return; + + await subscriptions.UpdateStatusAsync(stripeSubId, "past_due", ct); + + logger.LogWarning("Invoice payment failed: subscription {SubId} marked past_due", stripeSubId); + } + + // ── customer.subscription.updated ──────────────────────────────────────── + // Plan upgrades/downgrades via Stripe portal, or CancelAtPeriodEnd toggled. + private static async Task HandleSubscriptionUpdated( + Event stripeEvent, SubscriptionRepository subscriptions, + ILogger logger, CancellationToken ct) + { + if (stripeEvent.Data.Object is not Subscription stripeSub) return; + + var sub = await subscriptions.FindByStripeIdAsync(stripeSub.Id, ct); + if (sub == null) + { + logger.LogWarning("subscription.updated: no local record for {SubId}", stripeSub.Id); + return; + } + + sub.Status = stripeSub.Status; + sub.CancelAtPeriodEnd = stripeSub.CancelAtPeriodEnd; + sub.CurrentPeriodStart = stripeSub.CurrentPeriodStart; + sub.CurrentPeriodEnd = stripeSub.CurrentPeriodEnd; + await subscriptions.UpsertAsync(sub, ct); + + logger.LogInformation("Subscription updated: {SubId} status={Status} cancelAtEnd={Cancel}", + stripeSub.Id, stripeSub.Status, stripeSub.CancelAtPeriodEnd); + } + + // ── customer.subscription.deleted ──────────────────────────────────────── + // All retries exhausted, or user cancelled immediately. + private static async Task HandleSubscriptionDeleted( + Event stripeEvent, SubscriptionRepository subscriptions, + UserRepository users, ILogger logger, CancellationToken ct) + { + if (stripeEvent.Data.Object is not Subscription stripeSub) return; + + var sub = await subscriptions.FindByStripeIdAsync(stripeSub.Id, ct); + if (sub == null) + { + logger.LogWarning("subscription.deleted: no local record for {SubId}", stripeSub.Id); + return; + } + + await subscriptions.UpdateStatusAsync(stripeSub.Id, "canceled", ct); + await users.UpdatePlanAsync(sub.UserId, "free", ct); + + logger.LogInformation("Subscription deleted: user {UserId} downgraded to free", sub.UserId); + } +} diff --git a/src/Nalu.Web/Infrastructure/HangfireDashboardAuth.cs b/src/Nalu.Web/Infrastructure/HangfireDashboardAuth.cs new file mode 100644 index 0000000..98631dd --- /dev/null +++ b/src/Nalu.Web/Infrastructure/HangfireDashboardAuth.cs @@ -0,0 +1,27 @@ +using Hangfire; +using Hangfire.Dashboard; + +namespace Nalu.Web.Infrastructure; + +/// +/// Allows Hangfire Dashboard access only from localhost or users in the Admin role. +/// +public sealed class HangfireDashboardAuth : IDashboardAuthorizationFilter +{ + public bool Authorize(DashboardContext context) + { + var http = context.GetHttpContext(); + + // Always allow localhost (dev / ops access) + if (http.Connection.RemoteIpAddress is { } ip && + (System.Net.IPAddress.IsLoopback(ip) || + ip.Equals(http.Connection.LocalIpAddress))) + { + return true; + } + + // Allow authenticated Admin users in production + return http.User.Identity?.IsAuthenticated == true + && http.User.IsInRole("Admin"); + } +} diff --git a/src/Nalu.Web/Models/ExtractionRequest.cs b/src/Nalu.Web/Models/ExtractionRequest.cs index 8f3667c..16bf4ef 100644 --- a/src/Nalu.Web/Models/ExtractionRequest.cs +++ b/src/Nalu.Web/Models/ExtractionRequest.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; namespace Nalu.Web.Models; @@ -5,12 +6,15 @@ namespace Nalu.Web.Models; public record ExtractionRequest { [JsonPropertyName("agent_input")] + [MaxLength(600)] public required string AgentInput { get; init; } [JsonPropertyName("user_input")] + [MaxLength(1000)] public required string UserInput { get; init; } [JsonPropertyName("agent_context")] + [MaxLength(2000)] public string? AgentContext { get; init; } [JsonPropertyName("language")] diff --git a/src/Nalu.Web/Models/ExtractionResponse.cs b/src/Nalu.Web/Models/ExtractionResponse.cs index 53ce6f2..6257cd6 100644 --- a/src/Nalu.Web/Models/ExtractionResponse.cs +++ b/src/Nalu.Web/Models/ExtractionResponse.cs @@ -26,4 +26,8 @@ public record ExtractionResponse /// "scalar" = plain string value | "object" = JSON-as-string, parse before use | null = no value (obtained: false) [JsonPropertyName("value_format")] public string? ValueFormat { get; init; } + + /// Opaque engine code — internal use only, do not document publicly. + [JsonPropertyName("engine")] + public string? Engine { get; init; } } diff --git a/src/Nalu.Web/Models/ValidatorDefinition.cs b/src/Nalu.Web/Models/ValidatorDefinition.cs index b3ee4ff..4005993 100644 --- a/src/Nalu.Web/Models/ValidatorDefinition.cs +++ b/src/Nalu.Web/Models/ValidatorDefinition.cs @@ -25,6 +25,10 @@ public class ValidatorDefinition public List PostProcessors { get; set; } = []; public List Enrichers { get; set; } = []; + // When true: after a deterministic accept, pipeline queries nomes_br. + // All tokens found → certain=true. Any token missing → falls through to LLM. + public bool NameLookup { get; set; } + // Suggestions — flat (default) and localized (keyed by locale e.g. "pt-BR") public Dictionary Suggestions { get; set; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary> LocalizedSuggestions { get; set; } = new(StringComparer.OrdinalIgnoreCase); diff --git a/src/Nalu.Web/Nalu.Web.csproj b/src/Nalu.Web/Nalu.Web.csproj index b0e76b7..16f76d9 100644 --- a/src/Nalu.Web/Nalu.Web.csproj +++ b/src/Nalu.Web/Nalu.Web.csproj @@ -18,8 +18,14 @@ - + + + + + + + diff --git a/src/Nalu.Web/Pages/Casos/Parcelas48x.cshtml b/src/Nalu.Web/Pages/Casos/Parcelas48x.cshtml index e930ce7..cb85355 100644 --- a/src/Nalu.Web/Pages/Casos/Parcelas48x.cshtml +++ b/src/Nalu.Web/Pages/Casos/Parcelas48x.cshtml @@ -78,10 +78,10 @@
- +
-

Como o NALU AI resolve com validate_reply

+

Como o NaLU AI resolve com validate_reply

API RESPONSE — validate_reply
{
@@ -131,7 +131,7 @@
         

Código de integração

cURL
-
curl https://api.naluai.com/v1/extract/reply \
+            
curl https://api.naluai.dev/v1/extract/reply \
   -H "Authorization: Bearer SEU_TOKEN" \
   -H "Content-Type: application/json" \
   -d '{
@@ -142,7 +142,7 @@
 
             
JavaScript (n8n / Make)
const { reply_type, extracted_value, value_type } =
-  await $http.post('https://api.naluai.com/v1/extract/reply', {
+  await $http.post('https://api.naluai.dev/v1/extract/reply', {
     agent_message: $node['Agent'].json.message,
     user_reply: $node['User'].json.reply,
     language: 'pt-BR'
diff --git a/src/Nalu.Web/Pages/Checkout.cshtml b/src/Nalu.Web/Pages/Checkout.cshtml
index 6193920..1768298 100644
--- a/src/Nalu.Web/Pages/Checkout.cshtml
+++ b/src/Nalu.Web/Pages/Checkout.cshtml
@@ -12,6 +12,7 @@
         

Redirecionando para o pagamento…

+ @Html.AntiForgeryToken()
diff --git a/src/Nalu.Web/Pages/Docs/ApiReference.cshtml b/src/Nalu.Web/Pages/Docs/ApiReference.cshtml index e354166..462f2e3 100644 --- a/src/Nalu.Web/Pages/Docs/ApiReference.cshtml +++ b/src/Nalu.Web/Pages/Docs/ApiReference.cshtml @@ -1,8 +1,8 @@ @page "/docs/api-reference" @model Nalu.Web.Pages.Docs.ApiReferenceModel @{ - ViewData["Title"] = "API Reference — NALU AI Docs"; - ViewData["Description"] = "Referência completa de todos os endpoints NALU AI. Parâmetros, respostas e exemplos."; + ViewData["Title"] = "API Reference — NaLU AI Docs"; + ViewData["Description"] = "Referência completa de todos os endpoints NaLU AI. Parâmetros, respostas e exemplos."; }
@@ -11,7 +11,7 @@ Docs / API Reference

API Reference

-

Base URL: https://api.naluai.com/v1/extract

+

Base URL: https://api.naluai.dev/v1/extract

@@ -107,7 +107,7 @@
# Exemplo: detectar contraproposta de parcelas
-curl https://api.naluai.com/v1/extract/reply \
+curl https://api.naluai.dev/v1/extract/reply \
   -H "Authorization: Bearer SUA_API_KEY" \
   -H "Content-Type: application/json" \
   -d '{
diff --git a/src/Nalu.Web/Pages/Docs/Creditos.cshtml b/src/Nalu.Web/Pages/Docs/Creditos.cshtml
index c878046..e672e61 100644
--- a/src/Nalu.Web/Pages/Docs/Creditos.cshtml
+++ b/src/Nalu.Web/Pages/Docs/Creditos.cshtml
@@ -1,8 +1,8 @@
 @page "/docs/creditos"
 @model Nalu.Web.Pages.Docs.CreditosModel
 @{
-    ViewData["Title"] = "Créditos — NALU AI Docs";
-    ViewData["Description"] = "Como o sistema de créditos NALU AI funciona, custo por validador e limites de plano.";
+    ViewData["Title"] = "Créditos — NaLU AI Docs";
+    ViewData["Description"] = "Como o sistema de créditos NaLU AI funciona, custo por validador e limites de plano.";
 }
 
 
diff --git a/src/Nalu.Web/Pages/Docs/Erros.cshtml b/src/Nalu.Web/Pages/Docs/Erros.cshtml index a7ff58d..8adbcb7 100644 --- a/src/Nalu.Web/Pages/Docs/Erros.cshtml +++ b/src/Nalu.Web/Pages/Docs/Erros.cshtml @@ -1,8 +1,8 @@ @page "/docs/erros" @model Nalu.Web.Pages.Docs.ErrosModel @{ - ViewData["Title"] = "Erros — NALU AI Docs"; - ViewData["Description"] = "Códigos HTTP, payloads de erro e como lidar com rate limit e falhas de serviço na NALU AI."; + ViewData["Title"] = "Erros — NaLU AI Docs"; + ViewData["Description"] = "Códigos HTTP, payloads de erro e como lidar com rate limit e falhas de serviço na NaLU AI."; }
@@ -65,7 +65,7 @@ "credits_used": 100, "credits_limit": 100, "reset_at": "2026-05-11T00:00:00Z", - "upgrade_url": "https://naluai.com/precos", + "upgrade_url": "https://naluai.dev/precos", "hint": "Plano Starter: 15.000 créditos/mês sem limite diário. R$ 29/mês." }
@@ -85,7 +85,7 @@ "credits_used": 3000, "credits_limit": 3000, "reset_at": "2026-06-01T00:00:00Z", - "upgrade_url": "https://naluai.com/precos", + "upgrade_url": "https://naluai.dev/precos", "hint": "Upgrade para Starter por apenas R$ 0,0058 por validação." }
diff --git a/src/Nalu.Web/Pages/Docs/Fluxos.cshtml b/src/Nalu.Web/Pages/Docs/Fluxos.cshtml index a596a47..7578299 100644 --- a/src/Nalu.Web/Pages/Docs/Fluxos.cshtml +++ b/src/Nalu.Web/Pages/Docs/Fluxos.cshtml @@ -1,8 +1,8 @@ @page "/docs/fluxos" @model Nalu.Web.Pages.Docs.FluxosModel @{ - ViewData["Title"] = "Fluxos — NALU AI Docs"; - ViewData["Description"] = "Fluxos de integração NALU AI com n8n, Make, chatbots e máquinas de estado."; + ViewData["Title"] = "Fluxos — NaLU AI Docs"; + ViewData["Description"] = "Fluxos de integração NaLU AI com n8n, Make, chatbots e máquinas de estado."; }
@@ -83,7 +83,7 @@ Custo: 5 créditos por análise de resposta
# n8n — nó "HTTP Request"
 Method:  POST
-URL:     https://api.naluai.com/v1/extract/cpf
+URL:     https://api.naluai.dev/v1/extract/cpf
 Headers:
   Authorization: Bearer {{ $env.NALU_API_KEY }}
   Content-Type:  application/json
@@ -120,7 +120,7 @@ loop:
   if retries >= MAX_RETRIES:
     → escalar para humano ou abandonar coleta
 
-  # Usar sugestão do NALU como próxima mensagem do agente
+  # Usar sugestão do NaLU como próxima mensagem do agente
   proxima_mensagem = response.suggestion_to_agent
   coleta_atual = aguardar_resposta_usuario(proxima_mensagem)
diff --git a/src/Nalu.Web/Pages/Docs/Index.cshtml b/src/Nalu.Web/Pages/Docs/Index.cshtml index 6fd5c48..77f0f4f 100644 --- a/src/Nalu.Web/Pages/Docs/Index.cshtml +++ b/src/Nalu.Web/Pages/Docs/Index.cshtml @@ -1,14 +1,14 @@ @page "/docs" @model Nalu.Web.Pages.Docs.IndexModel @{ - ViewData["Title"] = "Documentação — NALU AI"; - ViewData["Description"] = "Documentação NALU AI: quickstart, API reference, MCP server, fluxos, créditos e erros."; + ViewData["Title"] = "Documentação — NaLU AI"; + ViewData["Description"] = "Documentação NaLU AI: quickstart, API reference, MCP server, fluxos, créditos e erros."; }

Documentação

-

Tudo que você precisa para integrar o NALU AI ao seu chatbot.

+

Tudo que você precisa para integrar o NaLU AI ao seu chatbot.

@@ -55,14 +55,20 @@
N8N
-
Integre NALU ao N8N sem código. Passo a passo com HTTP Request, IF e Switch.
+
Integre NaLU ao N8N sem código. Passo a passo com HTTP Request, IF e Switch.
+
+ + +
🔒
+
Segurança
+
Como o pipeline protege seus fluxos contra manipulações, injeção e comportamentos inesperados da IA.
Referência rápida
-
curl https://api.naluai.com/v1/extract/cpf \
+            
curl https://api.naluai.dev/v1/extract/cpf \
   -H "Authorization: Bearer SUA_API_KEY" \
   -H "Content-Type: application/json" \
   -d '{
diff --git a/src/Nalu.Web/Pages/Docs/Mcp.cshtml b/src/Nalu.Web/Pages/Docs/Mcp.cshtml
index 3405717..ecb4c1a 100644
--- a/src/Nalu.Web/Pages/Docs/Mcp.cshtml
+++ b/src/Nalu.Web/Pages/Docs/Mcp.cshtml
@@ -1,8 +1,8 @@
 @page "/docs/mcp"
 @model Nalu.Web.Pages.Docs.McpModel
 @{
-    ViewData["Title"] = "MCP Server — NALU AI Docs";
-    ViewData["Description"] = "Integre NALU AI com Claude Code, Cursor e qualquer cliente MCP via JSON-RPC 2.0.";
+    ViewData["Title"] = "MCP Server — NaLU AI Docs";
+    ViewData["Description"] = "Integre NaLU AI com Claude Desktop, Claude Code e qualquer cliente MCP via stdio.";
 }
 
 
@@ -11,7 +11,7 @@ Docs / MCP Server

MCP Server

-

Use os validadores NALU como ferramentas nativas no Claude Code, Cursor e qualquer cliente MCP.

+

Use os validadores NaLU como ferramentas nativas no Claude Desktop, Claude Code e qualquer cliente MCP.

@@ -23,20 +23,46 @@

O que é MCP?

Model Context Protocol (MCP) é um protocolo aberto que permite agentes de IA chamarem ferramentas externas de forma padronizada. - O NALU AI expõe todos os validadores como ferramentas MCP via JSON-RPC 2.0 sobre stdio ou HTTP/SSE. + O servidor MCP da NaLU AI roda localmente via stdio e chama a API REST da NaLU — sem expor sua chave via rede.

- +
-

Configurar no Claude Code

-

Adicione ao seu ~/.claude/settings.json:

+

Pré-requisitos

+ +
+ + +
+

Instalação

+

Clone ou baixe o servidor MCP e instale as dependências:

+
+
# Baixar o servidor
+git clone https://git.naluai.dev/nalu-mcp.git
+cd nalu-mcp
+
+# Instalar dependências
+npm install
+
+
+ + +
+

Configurar no Claude Desktop

+

+ Edite %APPDATA%\Claude\claude_desktop_config.json (Windows) + ou ~/Library/Application Support/Claude/claude_desktop_config.json (macOS): +

{
   "mcpServers": {
     "nalu": {
-      "command": "npx",
-      "args": ["-y", "@@naluai/mcp-server"],
+      "command": "node",
+      "args": ["/caminho/para/nalu-mcp/index.mjs"],
       "env": {
         "NALU_API_KEY": "SUA_API_KEY"
       }
@@ -44,66 +70,55 @@
   }
 }
-

Ou via HTTP (se preferir não usar npx):

-
-
{
-  "mcpServers": {
-    "nalu": {
-      "url": "https://api.naluai.com/mcp",
-      "headers": {
-        "Authorization": "Bearer SUA_API_KEY"
-      }
-    }
-  }
-}
-
+

Reinicie o Claude Desktop após salvar. Os validadores aparecem automaticamente como ferramentas disponíveis.

- +
-

Configurar no Cursor

-

Acesse Settings → MCP → Add Server e adicione:

+

Configurar no Claude Code (CLI)

-
Name: NALU AI
-URL:  https://api.naluai.com/mcp
-Headers:
-  Authorization: Bearer SUA_API_KEY
+
claude mcp add nalu \
+  --command node \
+  --args "/caminho/para/nalu-mcp/index.mjs" \
+  --env NALU_API_KEY=SUA_API_KEY

Ferramentas disponíveis

-

Após conectar, o agente de IA enxerga estas ferramentas:

+

Após conectar, o agente enxerga estas ferramentas:

+ @foreach (var t in new[] { - ("validate_cpf", "Extrai e valida CPF de texto em linguagem natural"), - ("validate_cep", "Extrai CEP e retorna endereço completo"), - ("validate_cnpj", "Extrai e valida CNPJ"), - ("validate_email", "Extrai email com correção de typos"), - ("validate_phone_br", "Extrai telefone brasileiro com DDD"), - ("validate_plate_br", "Extrai placa Mercosul ou formato antigo"), - ("validate_postal_code", "Código postal internacional"), - ("validate_full_name", "Extrai nome completo, ignora saudações"), - ("validate_yes_no", "Detecta sim/não em linguagem natural"), - ("validate_birthdate", "Extrai data de nascimento"), - ("validate_handoff", "Detecta intenção de falar com humano"), - ("validate_cancel_intent", "Classifica intenção de cancelamento"), - ("validate_company_name", "Extrai nome de empresa"), - ("validate_reply", "Analisa contexto conversacional completo"), + ("extract_name", "Extrai nome completo do diálogo", "2"), + ("extract_email", "Extrai email com correção de typos", "1"), + ("extract_yes_no", "Detecta sim/não em linguagem natural", "2"), + ("extract_birthdate", "Extrai data de nascimento, calcula idade", "2"), + ("extract_postal_code", "Código postal internacional (exceto Brasil)", "1"), + ("extract_company_name", "Extrai nome de empresa (LTDA, S/A, LLC...)", "2"), + ("detect_handoff", "Detecta intenção de falar com humano", "2"), + ("detect_cancel_intent", "Classifica intenção de cancelamento", "2"), + ("analyze_reply", "Analisa contexto conversacional completo", "5"), + ("extract_cpf", "Extrai e valida CPF (mod 11)", "1"), + ("extract_cnpj", "Extrai e valida CNPJ (mod 11)", "1"), + ("extract_cep", "Extrai CEP e retorna endereço completo", "3"), + ("extract_phone_br", "Extrai telefone brasileiro com DDD", "1"), + ("extract_plate_br", "Extrai placa Mercosul ou formato antigo", "1"), }) { + } @@ -111,17 +126,47 @@ Headers: - +
-

Exemplo de uso no Claude Code

+

Parâmetros das ferramentas

+

Todos os validadores (exceto analyze_reply) recebem:

+
+
Ferramenta DescriçãoCréditos
@t.Item1 @t.Item2@t.Item3 cr
+ + + + + + + + + + + + +
ParâmetroTipoDescrição
agent_inputstring *Mensagem do agente
user_inputstring *Resposta do usuário
languagestringIdioma (padrão: pt-BR)
+
+

analyze_reply usa agent_message e user_reply no lugar.

+
+ + +
+

Exemplo de uso

Prompt para o Claude:

-

"O usuário disse 'meu cpf é 111.444.777-35'. Use validate_cpf para extrair e validar."

+

"O usuário disse 'meu cpf é 111.444.777-35'. Use extract_cpf para extrair e validar."

Claude chama automaticamente:

-
validate_cpf({
+                
extract_cpf({
   "agent_input": "Qual o seu CPF?",
   "user_input":  "meu cpf é 111.444.777-35"
 })
+

Retorno:

+
{
+  "obtained": true,
+  "extracted_value": "111.444.777-35",
+  "confidence": "high",
+  "certain": true
+}
diff --git a/src/Nalu.Web/Pages/Docs/N8n.cshtml b/src/Nalu.Web/Pages/Docs/N8n.cshtml index c08e689..154d720 100644 --- a/src/Nalu.Web/Pages/Docs/N8n.cshtml +++ b/src/Nalu.Web/Pages/Docs/N8n.cshtml @@ -1,8 +1,8 @@ @page "/docs/n8n" @model Nalu.Web.Pages.Docs.N8nModel @{ - ViewData["Title"] = "Integração com N8N — NALU AI Docs"; - ViewData["Description"] = "Como usar o NALU AI no N8N para validar CPF, CEP, nome e mais em fluxos de chatbot sem escrever código."; + ViewData["Title"] = "Integração com N8N — NaLU AI Docs"; + ViewData["Description"] = "Como usar o NaLU AI no N8N para validar CPF, CEP, nome e mais em fluxos de chatbot sem escrever código."; }
@@ -24,7 +24,7 @@