commit ea6cdb5395f51809a67e340072d8a9c77b0b22d4 Author: Ricardo Carneiro Date: Sun May 10 16:39:04 2026 -0300 Initial commit — NALU AI web platform - ASP.NET Core 9 Razor Pages + Minimal API hybrid - 14 validators (CPF, CEP, CNPJ, email, phone, name, yes-no, birthdate, handoff, cancel-intent, company-name, plate-br, postal-code, validate_reply) - OAuth login (Google, Microsoft, GitHub) + cookie auth - MongoDB usage tracking + CEP cache collection - Stripe checkout with inline PriceData (no Price IDs) - MCP server for Claude Code / Cursor integration - Playground (10 calls/IP/day, no auth) - Docs: Quickstart, API Reference, N8N, MCP, Créditos, Erros, Fluxos - Credit system: 3 cr standard validators, 5 cr validate_reply - SmartSuggestion: contextual re-ask via IA when obtained=false - Per-IP rate limiting + daily cap + shared-IP abuse detection - Lightbox for comic images - Validadores page split: Brasileiros / Universais + Em breve Co-Authored-By: Claude Sonnet 4.6 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1000e71 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,24 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet build:*)", + "Bash(dotnet add:*)", + "Bash(find /c/vscode/qrrapido -type f -name \"*.cs\" -exec grep -l \"api/stripe\\\\|webhook\\\\|/api/\" {} \\\\;)", + "Bash(xargs grep:*)", + "Bash(grep -n \"extracted_value\" C:/vscode/nalu/src/Nalu.Api/Validators/*.md)", + "Bash(grep -n \"extracted_value\\\\|when_certain\\\\|when_uncertain\" C:/vscode/nalu/src/Nalu.Api/Validators/*.md)", + "Bash(grep -n \"MongoDB\\\\|Mongo\\\\|MongoDb\" C:/vscode/qrrapido/*.csproj C:/vscode/qrrapido/**/*.cs)", + "Bash(ctx csharp:*)", + "Bash(cat Data/Models/*.cs)", + "Bash(dotnet list:*)", + "Bash(ls /c/vscode/nalu/*.sln)", + "Bash(find /c/vscode/nalu/src/Nalu.Api -type f '\\(' -name '*.cs' -o -name '*.cshtml' -o -name '*.csproj' -o -name '*.json' '\\)' ! -path '*/bin/*' ! -path '*/obj/*' -exec sed -i 's/Nalu\\\\.Api/Nalu.Web/g' '{}' +)", + "Bash(powershell -Command \"Rename-Item 'C:\\\\vscode\\\\nalu\\\\src\\\\Nalu.Api' 'Nalu.Web'\")", + "Bash(cp:*)", + "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:*)" + ] + } +} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..3fbad5e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,73 @@ +name: Deploy NALU API + +on: + push: + branches: [main] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/nalu-api + +jobs: + build-and-push: + name: Build & Push Docker image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,prefix=sha- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + name: Deploy to OCI + needs: build-and-push + runs-on: ubuntu-latest + environment: production + + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.OCI_HOST }} + username: ${{ secrets.OCI_USER }} + key: ${{ secrets.OCI_SSH_KEY }} + script: | + cd ~/nalu + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + docker compose pull + docker compose up -d --remove-orphans + docker image prune -f diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53404b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,79 @@ +## Visual Studio / .NET + +# Build outputs +bin/ +obj/ +out/ + +# User-specific files +*.user +*.suo +*.userosscache +*.sln.docstates +.vs/ + +# NuGet +*.nupkg +*.snupkg +packages/ +project.lock.json +project.fragment.lock.json +artifacts/ + +# MSTest / VSTest +TestResults/ +*.trx +*.coverage +*.coveragexml + +# Publish profiles +PublishProfiles/ +*.pubxml +*.pubxml.user + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +## Secrets — NEVER commit + +appsettings.Development.json +appsettings.*.json +!appsettings.json + +# User secrets +secrets.json +.env +.env.* + +## OS + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +## Editors + +# JetBrains +.idea/ +*.iml + +# VS Code (keep .vscode/settings.json if team-shared) +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +## Docker +docker-compose.override.yml + +## Logs +*.log +logs/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..922513f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,40 @@ +# Project instructions + +## ctx — use before reading files + +This project has `ctx` available in PATH. Use it to understand the codebase **before** reading files directly. It produces compact markdown summaries that cost far fewer tokens than raw file content. + +### When to use + +**Always use `ctx` first** when you need to: +- Understand project structure → `ctx csharp project` +- Understand a file's structure → `ctx csharp outline ` +- Check build errors → `ctx csharp errors` +- Understand git state → `ctx git` +- Detect what stack this project uses → `ctx auto detect` + +### Workflow + +1. **Start of session:** run `ctx auto detect` to see what's here, then `ctx csharp project` for an overview. +2. **Before reading a file:** run `ctx csharp outline ` first. Only read the full file if the outline isn't enough (e.g., you need to see method body logic). +3. **After making changes:** run `ctx csharp errors` instead of `dotnet build` — the output is pre-filtered to only relevant diagnostics. +4. **Before committing:** run `ctx git` for a compact diff summary. + +### Available commands + +``` +ctx auto detect # detect stack(s) in current directory +ctx auto project # run project summary for all detected stacks + +ctx csharp project # .NET solution overview (projects, refs, packages) +ctx csharp outline # file structure without method bodies +ctx csharp errors # filtered dotnet build output (errors + top warnings) + +ctx git # branch, status, recent commits, diff summary +``` + +### Important + +- `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`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0f3652a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +WORKDIR /app +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 \ + --self-contained false \ + /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production + +ENTRYPOINT ["dotnet", "Nalu.Api.dll"] diff --git a/Nalu.sln b/Nalu.sln new file mode 100644 index 0000000..73c980e --- /dev/null +++ b/Nalu.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nalu.Web", "src\Nalu.Web\Nalu.Web.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nalu.Tests", "tests\Nalu.Tests\Nalu.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F01234567891}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + 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}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.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}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F01234567891}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Prompt1.md b/Prompt1.md new file mode 100644 index 0000000..39aaa1c --- /dev/null +++ b/Prompt1.md @@ -0,0 +1,838 @@ +\# Prompt 1 — NALU AI: Estrutura + Pipeline + Primeiro validador + + + +\## O que é o projeto + + + +NALU AI (Natural Language Understanding) é uma API + MCP server em ASP.NET Core 9 (C#) que recebe um trecho de diálogo entre agente e usuário e retorna a \*\*intenção real\*\* extraída, com validação determinística e sugestão de fala para o agente. + + + +Resolve problemas universais de chatbots: + +\- Usuário responde "Bom dia" quando perguntado o nome → bot grava "Bom Dia" como nome. + +\- Usuário "zoa" e diz "Meu nome é Xilofone" → bot aceita sem questionar. + +\- Agente oferece "20x de R$100", usuário responde "Bora em 48?" → bot extrai R$48 em vez de 48 parcelas. + +\- Usuário digita CEP errado → bot aceita sem validar. + + + +\--- + + + +\## Princípio fundamental: 1 validador = 1 endpoint = 1 MCP tool + + + +Cada validador tem seu \*\*próprio endpoint\*\* e sua \*\*própria tool MCP\*\*. A IA consumidora vê uma lista clara de endpoints/tools e sabe exatamente qual chamar sem ler parâmetros. + + + +Internamente todos os endpoints compartilham o mesmo pipeline de extração — a diferença é o validador `.md` carregado. + + + +\### Endpoints da API + + + +``` + +POST /v1/extract/name → validate\_full\_name.md + +POST /v1/extract/cpf → validate\_cpf.md + +POST /v1/extract/cep → validate\_cep.md + +POST /v1/extract/phone → validate\_phone\_br.md + +POST /v1/extract/email → validate\_email.md + +POST /v1/extract/yes-no → validate\_yes\_no.md + +GET /v1/validators → lista todos os validadores com descrição + +``` + + + +\### Tools MCP (espelho dos endpoints) + + + +``` + +nalu\_extract\_name → "Extrai o nome completo do usuário" + +nalu\_extract\_cpf → "Extrai e valida CPF do usuário" + +nalu\_extract\_cep → "Extrai CEP e retorna endereço completo" + +nalu\_extract\_phone → "Extrai telefone brasileiro com DDD" + +nalu\_extract\_email → "Extrai email com correção de typos" + +nalu\_extract\_yes\_no → "Detecta se o usuário disse sim ou não" + +``` + + + +Todos aceitam o \*\*mesmo body\*\* — sem campo `validator`, o endpoint já define qual usar: + + + +```json + +{ + + "agent\_input": "Bom dia! Qual seu nome?", + + "user\_input": "Bom dia!", + + "agent\_context": "Negociador de parcelamento.", + + "language": "pt-BR" + +} + +``` + + + +\--- + + + +\## Arquitetura + + + +\### Stack + +\- ASP.NET Core 9 (C#), .NET 10 + +\- LLM principal: Groq (Llama 3.3 70B, free tier) via API compatível OpenAI + +\- LLM fallback: OpenRouter (modelos baratos) + +\- Cache: in-memory (V1) + +\- Banco: PostgreSQL (logs, billing, API keys) — EF Core + +\- Container Docker único + + + +\### Pipeline de extração (compartilhado) + + + +``` + +Request chega no endpoint específico (ex: /v1/extract/name) + + ↓ + +Auth (API key via header Authorization: Bearer {key}) + + ↓ + +Rate limit por plano + + ↓ + +Resolve qual validador (.md) usar baseado no endpoint + + ↓ + +Cache lookup (hash de: validator\_id + agent\_input + user\_input + language) + + ↓ cache miss + +Carregar validador (.md do disco, com cache em memória) + + ↓ + +Camada 1 — Regras determinísticas (regex, stop words, constraints) + + ↓ não resolveu + +Camada 2 — LLM com prompt do validador (Groq → fallback OpenRouter) + + ↓ + +Pós-processamento (normalização, dígito verificador, etc.) + + ↓ + +Enriquecimento externo (ViaCEP etc., se o validador declarar) + + ↓ + +Montar sugestão de fala (template do validador) + + ↓ + +Salvar log + retornar resposta + +``` + + + +\--- + + + +\## Estrutura de pastas + + + +``` + +src/ + + Nalu.Api/ + + Program.cs + + appsettings.json + + appsettings.Development.json + + + + Endpoints/ + + ExtractEndpoints.cs # Registra todos os POST /v1/extract/{tipo} + + ValidatorsEndpoints.cs # GET /v1/validators + + + + Models/ + + ExtractionRequest.cs # Body compartilhado por todos os endpoints + + ExtractionResponse.cs # Response compartilhada + + ValidatorInfo.cs # Modelo para GET /v1/validators + + ValidatorDefinition.cs # Validador parseado do .md + + + + Services/ + + ExtractionPipeline.cs # Orquestra o pipeline (recebe validator\_id) + + ValidatorLoader.cs # Parseia .md, cache em memória, FileSystemWatcher + + DeterministicLayer.cs # Camada 1: stop words, regex, constraints + + LlmExtractionService.cs # Camada 2: Groq/OpenRouter + + PostProcessorRegistry.cs # Registry de IPostProcessor por nome + + EnrichmentService.cs # Orquestra enrichers + + SuggestionBuilder.cs # Monta suggestion\_to\_agent + + CacheService.cs # IMemoryCache com hash + + AuthService.cs # Valida API key, retorna plano + + RateLimitService.cs # Rate limit por plano + + + + PostProcessors/ + + IPostProcessor.cs + + CapitalizeProperName.cs + + RemoveTitles.cs + + + + Enrichers/ + + IEnricher.cs + + + + Infrastructure/ + + GroqClient.cs + + OpenRouterClient.cs + + NaluDbContext.cs + + + + Validators/ # ← CADA .md É UM VALIDADOR + + validate\_full\_name.md + + + +tests/ + + Nalu.Tests/ + + ValidatorLoaderTests.cs + + DeterministicLayerTests.cs + + PipelineIntegrationTests.cs + + + +Dockerfile + +``` + + + +\### Registro dos endpoints (Minimal APIs) + + + +```csharp + +// ExtractEndpoints.cs + +public static class ExtractEndpoints + +{ + + public static void MapExtractEndpoints(this WebApplication app) + + { + + var group = app.MapGroup("/v1/extract") + + .RequireAuthorization(); + + + + group.MapPost("/name", async (ExtractionRequest req, ExtractionPipeline pipeline) => + + await pipeline.ExecuteAsync("validate\_full\_name", req)); + + + + // Prompt 2 adiciona os demais aqui — mesma estrutura, 2 linhas cada + + } + +} + +``` + + + +\--- + + + +\## Formato dos validadores (.md) + + + +O `ValidatorLoader` parseia seções `##` do markdown. Cada seção vira uma propriedade do `ValidatorDefinition`. + + + +\### Exemplo completo: `validate\_full\_name.md` + + + +```markdown + +\# validate\_full\_name + + + +Extrai o nome completo do usuário 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. + + + +\## deterministic\_rules + + + +\### stop\_words + +bom dia, boa tarde, boa noite, olá, oi, tudo bem, e aí, fala, eae, opa + + + +\### reject\_patterns + +\- ^(não|nao|sei la|sei lá|tanto faz|qualquer|nenhum|nada)$ + +\- ^\\d+$ + + + +\### accept\_patterns + +\- ^meu nome é\\s+(.+)$ + +\- ^me chamo\\s+(.+)$ + +\- ^sou o\\s+(.+)$ + +\- ^sou a\\s+(.+)$ + +\- ^pode me chamar de\\s+(.+)$ + + + +\### constraints + +\- min\_length: 2 + +\- must\_have\_alpha: 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. + + + +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). + + + +Diálogo: + +Agente: {{agent\_input}} + +Usuário: {{user\_input}} + + + +Contexto do agente: {{agent\_context}} + + + +Responda SOMENTE com JSON válido, sem markdown, sem explicação: + +{ + + "extracted\_value": "nome extraído ou null", + + "certain": true/false, + + "reasoning": "explicação curta de 1 linha" + +} + + + +\## few\_shot\_examples + + + +\### example 1 + +\- agent\_input: Bom dia! Qual seu nome completo? + +\- user\_input: Bom dia! + +\- 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"} + + + +\### 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"} + + + +\### example 4 + +\- 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"} + + + +\## post\_processors + +\- capitalize\_proper\_name + +\- remove\_titles + + + +\## enrichment + +(nenhum) + + + +\## suggestions + + + +\### when\_null\_greeting + +{{greeting\_response}} Mas preciso do seu nome completo para continuar. Pode me dizer? + + + +\### when\_null\_evasive + +Sem problemas, mas preciso do seu nome para prosseguir. Qual seu nome completo? + + + +\### when\_uncertain + +Só confirmando: seu nome é {{extracted\_value}}? Pode confirmar? + + + +\### when\_certain + +(sem sugestão — agente segue o fluxo) + +``` + + + +\--- + + + +\## Contrato da API + + + +\### Request (mesmo para todos os endpoints) + + + +``` + +POST /v1/extract/name + +Authorization: Bearer {api\_key} + +Content-Type: application/json + +``` + + + +```json + +{ + + "agent\_input": "Bom dia! Qual seu nome completo?", + + "user\_input": "Bom dia!", + + "agent\_context": "Negociador de parcelamento. Precisa de nome e CPF.", + + "language": "pt-BR" + +} + +``` + + + +\### Response (mesma estrutura para todos) + + + +```json + +{ + + "obtained": false, + + "extracted\_value": null, + + "confidence": 0.15, + + "certain": false, + + "reasoning": "Usuário respondeu com saudação, não informou o nome", + + "suggestion\_to\_agent": "Bom dia! Mas preciso do seu nome completo para continuar. Pode me dizer?", + + "validator\_used": "validate\_full\_name", + + "engine": "deterministic" + +} + +``` + + + +\### Lógica obtained × certain × suggestion + + + +| Cenário | obtained | certain | suggestion\_to\_agent | + +|---|---|---|---| + +| Valor extraído e confiável | true | true | null | + +| Valor extraído mas duvidoso | true | false | Frase de confirmação | + +| Nada extraído (saudação, evasão) | false | false | Frase re-pedindo o dado | + +| Erro de formato (CPF inválido) | false | false | Frase explicando o erro | + + + +\### GET /v1/validators + + + +```json + +{ + + "validators": \[ + + { + + "id": "validate\_full\_name", + + "endpoint": "/v1/extract/name", + + "mcp\_tool": "nalu\_extract\_name", + + "description": "Extrai o nome completo do usuário", + + "version": "1.0", + + "languages": \["pt-BR", "es-ES", "en-US"] + + } + + ] + +} + +``` + + + +\--- + + + +\## O que gerar + + + +1\. Projeto ASP.NET Core 9 com Minimal APIs e a estrutura de pastas acima + +2\. `ExtractEndpoints.cs` com `POST /v1/extract/name` + +3\. `ValidatorsEndpoints.cs` com `GET /v1/validators` + +4\. `ExtractionPipeline.cs` — orquestra o pipeline, recebe `validator\_id` + `ExtractionRequest` + +5\. `ValidatorLoader.cs` — parseia `.md`, `ConcurrentDictionary` + `FileSystemWatcher` + +6\. `DeterministicLayer.cs` — stop\_words, reject/accept patterns, constraints + +7\. `LlmExtractionService.cs` — monta messages e chama Groq `/chat/completions` com `response\_format: json\_object` + +8\. `PostProcessorRegistry.cs` + `CapitalizeProperName.cs` + `RemoveTitles.cs` + +9\. `SuggestionBuilder.cs` — templates com `{{extracted\_value}}`, `{{greeting\_response}}` + +10\. `CacheService.cs` — `IMemoryCache`, TTL configurável + +11\. `AuthService.cs` — valida API key contra lista em appsettings (sem banco por agora) + +12\. `RateLimitService.cs` — rate limit por plano + +13\. `GroqClient.cs` + `OpenRouterClient.cs` — HttpClients tipados, fallback automático em 429/5xx + +14\. `validate\_full\_name.md` completo + +15\. Testes para ValidatorLoader, DeterministicLayer, integração do pipeline + +16\. `appsettings.json` com Groq, OpenRouter, planos, cache, API keys de teste + +17\. `Dockerfile` + +18\. Models (ExtractionRequest, ExtractionResponse, ValidatorInfo, ValidatorDefinition) + + + +\### appsettings.json + + + +```json + +{ + + "Groq": { + + "ApiKey": "YOUR\_GROQ\_API\_KEY", + + "BaseUrl": "https://api.groq.com/openai/v1", + + "Model": "llama-3.3-70b-versatile", + + "MaxTokens": 500, + + "Temperature": 0.1 + + }, + + "OpenRouter": { + + "ApiKey": "YOUR\_OPENROUTER\_API\_KEY", + + "BaseUrl": "https://openrouter.ai/api/v1", + + "Model": "mistralai/mistral-7b-instruct", + + "MaxTokens": 500, + + "Temperature": 0.1 + + }, + + "Plans": { + + "free": { "monthly\_limit": 2000, "daily\_limit": 100 }, + + "hobby": { "monthly\_limit": 5000, "daily\_limit": null }, + + "indie": { "monthly\_limit": 25000, "daily\_limit": null }, + + "pro": { "monthly\_limit": 100000, "daily\_limit": null } + + }, + + "Cache": { "DefaultTtlMinutes": 60 }, + + "ApiKeys": \[ + + { "key": "nalu-test-key-001", "plan": "free", "owner": "test" } + + ] + +} + +``` + + + +\### Notas de implementação + +\- `ValidatorLoader`: regex por seção `##`. `ConcurrentDictionary` como cache. `FileSystemWatcher` invalida ao mudar `.md`. + +\- `GroqClient`: `/chat/completions`, system message = prompt + few-shot, user message = diálogo. `response\_format: { type: "json\_object" }`. + +\- Fallback: Groq 429 ou 5xx → retry no OpenRouter. + +\- `SuggestionBuilder`: detectar saudação no `user\_input` para `{{greeting\_response}}`. + +\- DI para tudo no `Program.cs`. + + + +\### NÃO incluir + +\- MCP server (Prompt 2) + +\- Validadores além do validate\_full\_name (Prompt 2) + +\- Painel admin, billing, PostgreSQL + diff --git a/Prompt2.md b/Prompt2.md new file mode 100644 index 0000000..42f9964 --- /dev/null +++ b/Prompt2.md @@ -0,0 +1,242 @@ +# Prompt 2 — NALU AI: Validadores adicionais + MCP Server + +> **Pré-requisito:** o projeto do Prompt 1 já está funcionando com `POST /v1/extract/name` e o validador `validate_full_name.md`. + +--- + +## Parte A — Novos validadores + endpoints + +Para cada validador abaixo: +1. Criar o arquivo `.md` em `Validators/` seguindo o mesmo formato de `validate_full_name.md` +2. Registrar o endpoint em `ExtractEndpoints.cs` (2 linhas, mesmo padrão) +3. Criar pós-processadores e enrichers necessários + +### 1. `validate_cpf.md` → `POST /v1/extract/cpf` + +Extrai e valida CPF (11 dígitos, algoritmo mod 11). + +**Config MCP:** +- mcp_tool: nalu_extract_cpf +- mcp_description: Extrai e valida o CPF do usuário. Valida dígitos verificadores automaticamente. Se certain=true, o CPF é válido e pode ser usado. Se certain=false, use suggestion_to_agent para pedir correção. + +**Regras determinísticas:** +- Aceitar: `123.456.789-09`, `12345678909`, `123 456 789 09` +- Rejeitar sequências repetidas: `111.111.111-11`, `000.000.000-00`, etc. +- Validar dígitos verificadores (mod 11) +- Rejeitar se não tiver exatamente 11 dígitos numéricos + +**Prompt LLM:** extrair CPF mesmo quando informal ("meu cpf é um dois três quatro...") ou no meio de frase longa. + +**Few-shot (mínimo 5):** +- CPF válido direto +- CPF no meio de frase +- CPF com dígito errado +- Resposta evasiva ("não lembro") +- CPF escrito por extenso + +**Pós-processador:** `ValidateCpfDigit` — validar mod 11, formatar `XXX.XXX.XXX-XX` + +**Sugestões:** +- when_null_evasive: "Preciso do seu CPF para continuar. São 11 dígitos, pode digitar?" +- when_invalid: "Esse CPF parece estar incorreto. Pode verificar e digitar novamente?" +- when_uncertain: "Só confirmando: seu CPF é {{extracted_value}}?" + +--- + +### 2. `validate_cep.md` → `POST /v1/extract/cep` + +Extrai CEP (8 dígitos) e enriquece com endereço via ViaCEP. + +**Config MCP:** +- mcp_tool: nalu_extract_cep +- mcp_description: Extrai o CEP do usuário e retorna o endereço completo (logradouro, bairro, cidade, estado). Se certain=true, o endereço está confirmado. Se obtained=false, o CEP é inválido — use suggestion_to_agent. + +**Regras determinísticas:** +- Aceitar: `01001-000`, `01001000` +- Rejeitar se não tiver 8 dígitos +- Rejeitar `00000-000` + +**Enricher:** `ViaCepEnricher` +- Chamar `https://viacep.com.br/ws/{cep}/json/` +- Se retornar `erro: true`, marcar obtained: false +- Fallback: `https://brasilapi.com.br/api/cep/v2/{cep}` + +**extracted_value quando enriquecido:** +```json +{ + "cep": "01001-000", + "logradouro": "Praça da Sé", + "bairro": "Sé", + "cidade": "São Paulo", + "estado": "SP" +} +``` + +**Sugestões:** +- when_invalid: "Esse CEP não parece válido. Pode verificar? Formato: XXXXX-XXX" +- when_enriched_certain: "Encontrei: {{logradouro}}, {{bairro}} — {{cidade}}/{{estado}}. Correto?" +- when_not_found: "Não encontrei esse CEP. Pode verificar e digitar novamente?" + +--- + +### 3. `validate_phone_br.md` → `POST /v1/extract/phone` + +Extrai telefone brasileiro com DDD. + +**Config MCP:** +- mcp_tool: nalu_extract_phone +- mcp_description: Extrai o número de telefone brasileiro do usuário, incluindo DDD. Formata automaticamente. Se certain=true, o número é válido. + +**Regras determinísticas:** +- Aceitar: `(11) 99999-9999`, `11999999999`, `+55 11 99999-9999`, `11 99999 9999` +- DDD válido: 11-99 +- Celular: 9 dígitos começando com 9 +- Fixo: 8 dígitos começando com 2-5 + +**Pós-processador:** `FormatPhone` — normalizar para `(XX) XXXXX-XXXX` ou `(XX) XXXX-XXXX` + +**Sugestões:** +- when_invalid: "Não consegui identificar o telefone. Pode digitar com DDD? Ex: (11) 99999-9999" +- when_uncertain: "Seu telefone é {{extracted_value}}? Pode confirmar?" + +--- + +### 4. `validate_email.md` → `POST /v1/extract/email` + +Extrai email com correção de typos. + +**Config MCP:** +- mcp_tool: nalu_extract_email +- mcp_description: Extrai o email do usuário com correção automática de typos em domínios comuns (gmail, hotmail, outlook). Se houve correção, certain=false para o agente confirmar. + +**Regras determinísticas:** +- Regex de email +- Rejeitar sem @ ou sem ponto após @ + +**Pós-processador:** `CorrectEmailTypos` +- `gmail.com.br` → `gmail.com` +- `gamil.com`, `gmaill.com`, `gnail.com` → `gmail.com` +- `hotmal.com`, `hotmial.com` → `hotmail.com` +- `outlok.com` → `outlook.com` +- `yaho.com` → `yahoo.com` + +Se corrigiu: certain=false, guardar original para a sugestão. + +**Sugestões:** +- when_corrected: "Seu email é {{extracted_value}}? (notamos um possível erro em '{{original}}')" +- when_invalid: "Não parece um email válido. Pode digitar novamente?" + +--- + +### 5. `validate_yes_no.md` → `POST /v1/extract/yes-no` + +Detecta sim/não. + +**Config MCP:** +- mcp_tool: nalu_extract_yes_no +- mcp_description: Detecta se o usuário respondeu sim ou não. Retorna true (sim), false (não), ou null (ambíguo). Ideal para confirmações. Se ambíguo, use suggestion_to_agent para re-perguntar. + +**Regras determinísticas:** +- Sim: sim, s, ss, yes, si, pode, claro, com certeza, bora, vamos, isso, exato, beleza, blz, ok, tá, aham, uhum, positivo, aceito, quero, concordo, fechado, feito, show, top, massa, dale +- Não: não, nao, n, no, nope, nada, nunca, jamais, nem, negativo, recuso, discordo, não quero, de jeito nenhum + +Quando ambíguo → LLM interpreta com contexto. + +**extracted_value:** `true`, `false`, ou `null` + +**Sugestões:** +- when_ambiguous: "Desculpa, não entendi. Pode responder sim ou não?" + +--- + +## Parte B — MCP Server + +Adicionar MCP ao mesmo projeto ASP.NET Core, expondo cada validador como uma tool. + +### Transporte +**Streamable HTTP** no endpoint `/mcp`. + +### Tools registradas + +Cada validador gera uma tool MCP automaticamente, usando os campos `mcp_tool` e `mcp_description` do seu arquivo `.md`: + +``` +nalu_extract_name — parâmetros: agent_input, user_input, agent_context?, language? +nalu_extract_cpf — parâmetros: agent_input, user_input, agent_context?, language? +nalu_extract_cep — parâmetros: agent_input, user_input, agent_context?, language? +nalu_extract_phone — parâmetros: agent_input, user_input, agent_context?, language? +nalu_extract_email — parâmetros: agent_input, user_input, agent_context?, language? +nalu_extract_yes_no — parâmetros: agent_input, user_input, agent_context?, language? +``` + +Todos compartilham os mesmos parâmetros de entrada e o mesmo formato de resposta. Internamente, cada tool chama `ExtractionPipeline.ExecuteAsync(validator_id, request)`. + +### Registro dinâmico de tools + +O MCP server deve gerar a lista de tools **automaticamente** a partir dos arquivos `.md` na pasta `Validators/`. Quando um novo `.md` é adicionado com as configs `mcp_tool` e `mcp_description`, ele aparece automaticamente em `tools/list`. + +### Instrução para o agente consumidor + +Incluir no `mcp_description` de cada tool: +> Se `certain: true`, aceite o `extracted_value` e prossiga para o próximo passo. +> Se `certain: false` e `suggestion_to_agent` não é null, use a sugestão como próxima mensagem ao usuário. +> Se `obtained: false`, use a sugestão para re-pedir o dado ao usuário. + +### Auth +- API key via `Authorization: Bearer {key}` no header HTTP +- Mesma validação de auth e rate limit do REST + +### Implementação + +Verificar se existe pacote NuGet `ModelContextProtocol` maduro para C#. Se sim, usá-lo. Se não, implementar manualmente: +- Endpoint POST `/mcp` (Streamable HTTP) +- Responder a `initialize`, `tools/list`, `tools/call` +- JSON-RPC 2.0 +- Content-Type: `application/json` + +### Arquivo de setup para devs + +Criar `mcp-config.json` na raiz para facilitar conexão: + +```json +{ + "mcpServers": { + "nalu": { + "url": "http://localhost:5000/mcp", + "headers": { + "Authorization": "Bearer YOUR_API_KEY" + } + } + } +} +``` + +### Testes MCP + +- Teste que simula `tools/list` e verifica que todas as tools estão listadas +- Teste que chama `tools/call` no `nalu_extract_name` e verifica a resposta +- Teste que verifica que um novo `.md` adicionado aparece em `tools/list` + +--- + +## Resumo de arquivos a criar/modificar + +### Criar: +- `Validators/validate_cpf.md` +- `Validators/validate_cep.md` +- `Validators/validate_phone_br.md` +- `Validators/validate_email.md` +- `Validators/validate_yes_no.md` +- `PostProcessors/ValidateCpfDigit.cs` +- `PostProcessors/FormatPhone.cs` +- `PostProcessors/CorrectEmailTypos.cs` +- `Enrichers/ViaCepEnricher.cs` +- `Mcp/NaluMcpServer.cs` (ou equivalente) +- `mcp-config.json` +- Testes para os novos validadores e MCP + +### Modificar: +- `ExtractEndpoints.cs` — adicionar 5 novos endpoints +- `PostProcessorRegistry.cs` — registrar novos processadores +- `EnrichmentService.cs` — registrar ViaCepEnricher +- `Program.cs` — registrar MCP server e novos serviços \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ae0eefd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +services: + nalu-api: + build: + context: . + dockerfile: Dockerfile + image: nalu-api:latest + container_name: nalu-api + restart: unless-stopped + ports: + - "8080:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ConnectionStrings__MongoDB=${MONGODB_CONNECTION_STRING} + - Groq__ApiKey=${GROQ_API_KEY} + - OAuth__Google__ClientId=${GOOGLE_CLIENT_ID} + - OAuth__Google__ClientSecret=${GOOGLE_CLIENT_SECRET} + - OAuth__Microsoft__ClientId=${MICROSOFT_CLIENT_ID} + - OAuth__Microsoft__ClientSecret=${MICROSOFT_CLIENT_SECRET} + depends_on: + - mongo + networks: + - nalu-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + + mongo: + image: mongo:7 + container_name: nalu-mongo + restart: unless-stopped + ports: + - "27017:27017" + environment: + - MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER:-admin} + - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD} + - MONGO_INITDB_DATABASE=naluai + volumes: + - mongo-data:/data/db + networks: + - nalu-net + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 30s + timeout: 10s + retries: 3 + +volumes: + mongo-data: + +networks: + nalu-net: + driver: bridge diff --git a/docs/site-brief.md b/docs/site-brief.md new file mode 100644 index 0000000..0b892f3 --- /dev/null +++ b/docs/site-brief.md @@ -0,0 +1,130 @@ +# NALU AI — Site Brief + +> Reference doc for context after compaction. Last updated: 2026-05-09. +> Comics/quadrinhos sections: placeholders only — user generates images in Gemini separately. +> APIs: ALL 12 validators already implemented and functional in the backend. + +## Stack + +- Razor Pages + TailwindCSS + HTMX (inside same ASP.NET Core 9 project) +- Stripe (analyze qrrapido for patterns — price_data dynamic, idempotency keys) +- Database: TBD (confirm schema before implementing) +- MCP: Streamable HTTP primary + SSE fallback + +## Pages + +``` +/ Landing (one-page, all sections) +/playground Public test, no auth, 50 calls/IP/day +/docs Docs hub +/docs/quickstart +/docs/api-reference +/docs/mcp +/docs/state-machine State machine concept + Mermaid diagrams +/validadores Grid of 12 validators +/validadores/{id} Individual validator page +/casos Use case hub (SEO) +/casos/extrator-de-nome +/casos/parcelas-48x +/casos/cep-via-conversa +/casos/cpf-em-chatbot +/precos Plans + Stripe checkout +/login +/painel Dashboard + keys + plan + invoices +/sobre +``` + +## Identity / Branding + +- Name: **NALU AI** — NAtural Language Understanding +- Color rule: NA (Natural) = light color, LU = dark color, same across logo + subtitle +- Font: Inter or Plus Jakarta Sans (Google Fonts) +- Minimal, developer-friendly, white background +- Icons: Lucide (line icons) + +## Landing sections (in order) + +1. Hero — tagline "Seu chatbot está gravando 'Bom Dia' como nome do cliente" +2. Before/after demo (3 examples: name, parcelas 48x, CEP errado) +3. How it works (3 steps) +4. **[COMIC PLACEHOLDER 1]** — `` +5. Validator catalog grid (Universal 7 + Brasil 5) +6. State machine section — how validators chain into flows +7. **[COMIC PLACEHOLDER 2]** — `` +8. Code snippets (tabs: cURL | JS | C# | Python | n8n) +9. Pricing summary (3 cols) +10. FAQ (5 questions) +11. **[COMIC PLACEHOLDER 3 — optional]** — `` +12. Final CTA + footer + +## Plans / Pricing + +| Plan | Price | Requests/month | +|------------|------------|----------------| +| Free | R$ 0 | 2.000 (100/day rolling) | +| Indie | R$ 49/mês | 25.000 | +| Pro | R$ 149/mês | 100.000 | +| Enterprise | Consulta | 500k+ | + +## Playground + +- Endpoint: `POST /v1/playground/extract/{validator}` (no auth, rate limit by IP) +- Rate limit: 50 calls/IP/day — on exceed: prompt to sign up +- Shows side-by-side: "bot tradicional" vs "NALU AI" +- Dropdown with all validators + pre-loaded examples from `.md` files +- "Ver código" button shows cURL/JS/C# equivalent + +## Stripe (CRITICAL — replicate qrrapido patterns) + +- `price_data` dynamic — NO hardcoded `price_id` +- Idempotency keys on all mutations +- Webhooks with idempotence table (`webhook_events` with `event_id` PK) +- Mandatory events: checkout.session.completed, subscription.updated, subscription.deleted, invoice.payment_failed +- Validate `Stripe-Signature` header on every webhook + +## API Keys + +- Prefix: `nalu_` + hash +- Show full key ONCE after creation only +- Store HASH only (not plaintext) +- Allow multiple keys per account +- Soft-delete for revocation (keep for audit) +- Rate limit tied to PLAN, not to individual key + +## MCP / Smithery + +- Analyze qrrapido for ALL adjustments made for Smithery compatibility +- Transport: Streamable HTTP (`POST /mcp`) primary, SSE fallback (`GET /sse`) +- Header required: `MCP-Protocol-Version: 2025-11-25` +- `smithery.yaml` with API key config schema +- CORS: allow Smithery origin + MCP client origins + +## Database schema (to confirm before implementing) + +Tables needed: +- `users` (id, email, plan, stripe_customer_id, created_at) +- `api_keys` (id, user_id, key_hash, prefix, name, revoked_at, created_at) +- `usage` (id, api_key_id, date, count) +- `webhook_events` (event_id PK, event_type, processed_at) + +## SEO pages (/casos) + +Template per page: +1. H1: "Como [resolver X] em chatbots" +2. Intro: real problem story +3. "O bug em ação" — dialogue diagram +4. "Como NALU resolve" — API response +5. Code tabs +6. CTA → playground + +## Priority order + +Landing → Playground → Precos+Stripe → Painel → Docs → Casos + +## DO NOT implement now (V2+) + +- Detailed call logs +- Custom validators +- Advanced analytics +- Team management +- Comic images (user generates externally) diff --git a/mcp-config.json b/mcp-config.json new file mode 100644 index 0000000..c0e3bdd --- /dev/null +++ b/mcp-config.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "nalu": { + "url": "http://localhost:5282/mcp", + "headers": { + "Authorization": "Bearer nalu-test-key-001" + } + } + } +} diff --git a/src/Nalu.Api/Data/Models/ApiKey.cs b/src/Nalu.Api/Data/Models/ApiKey.cs new file mode 100644 index 0000000..3a296d1 --- /dev/null +++ b/src/Nalu.Api/Data/Models/ApiKey.cs @@ -0,0 +1,35 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Nalu.Web.Data.Models; + +public class ApiKey +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); + + [BsonElement("key")] + public required string Key { get; set; } + + [BsonElement("plan")] + public required string Plan { get; set; } + + [BsonElement("owner")] + public required string Owner { get; set; } + + [BsonElement("user_id")] + public string? UserId { get; set; } + + [BsonElement("label")] + public string? Label { get; set; } + + [BsonElement("is_active")] + public bool IsActive { get; set; } = true; + + [BsonElement("created_at")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [BsonElement("last_used_at")] + public DateTime? LastUsedAt { get; set; } +} diff --git a/src/Nalu.Api/Data/Models/NaluUser.cs b/src/Nalu.Api/Data/Models/NaluUser.cs new file mode 100644 index 0000000..5c8ff72 --- /dev/null +++ b/src/Nalu.Api/Data/Models/NaluUser.cs @@ -0,0 +1,41 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Nalu.Web.Data.Models; + +public class NaluUser +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); + + [BsonElement("email")] + public required string Email { get; set; } + + [BsonElement("name")] + public string? Name { get; set; } + + [BsonElement("provider")] + public required string Provider { get; set; } // "google" | "microsoft" + + [BsonElement("provider_id")] + public required string ProviderId { get; set; } + + [BsonElement("picture_url")] + public string? PictureUrl { get; set; } + + [BsonElement("plan")] + public string Plan { get; set; } = "free"; + + [BsonElement("stripe_customer_id")] + public string? StripeCustomerId { get; set; } + + [BsonElement("created_at")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [BsonElement("last_login_at")] + public DateTime LastLoginAt { get; set; } = DateTime.UtcNow; + + [BsonElement("is_active")] + public bool IsActive { get; set; } = true; +} diff --git a/src/Nalu.Api/Data/Models/Subscription.cs b/src/Nalu.Api/Data/Models/Subscription.cs new file mode 100644 index 0000000..fd34e88 --- /dev/null +++ b/src/Nalu.Api/Data/Models/Subscription.cs @@ -0,0 +1,42 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Nalu.Web.Data.Models; + +public class Subscription +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); + + [BsonElement("user_id")] + public required string UserId { get; set; } + + [BsonElement("stripe_subscription_id")] + public required string StripeSubscriptionId { get; set; } + + [BsonElement("stripe_customer_id")] + public required string StripeCustomerId { get; set; } + + [BsonElement("plan")] + public required string Plan { get; set; } + + /// "active" | "canceled" | "past_due" | "trialing" + [BsonElement("status")] + public string Status { get; set; } = "active"; + + [BsonElement("current_period_start")] + public DateTime CurrentPeriodStart { get; set; } + + [BsonElement("current_period_end")] + public DateTime CurrentPeriodEnd { get; set; } + + [BsonElement("cancel_at_period_end")] + public bool CancelAtPeriodEnd { get; set; } + + [BsonElement("created_at")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [BsonElement("updated_at")] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Nalu.Api/Data/Models/UsageDaily.cs b/src/Nalu.Api/Data/Models/UsageDaily.cs new file mode 100644 index 0000000..d0fbaf6 --- /dev/null +++ b/src/Nalu.Api/Data/Models/UsageDaily.cs @@ -0,0 +1,33 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Nalu.Web.Data.Models; + +/// +/// One document per (api_key + date). Counters updated atomically via $inc. +/// +public class UsageDaily +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); + + [BsonElement("api_key")] + public required string ApiKey { get; set; } + + [BsonElement("plan")] + public required string Plan { get; set; } + + /// Format: "YYYY-MM-DD" + [BsonElement("date")] + public required string Date { get; set; } + + [BsonElement("daily_count")] + public int DailyCount { get; set; } + + [BsonElement("monthly_count")] + public int MonthlyCount { get; set; } + + [BsonElement("year_month")] + public required string YearMonth { get; set; } // "YYYY-MM" +} diff --git a/src/Nalu.Api/Data/Models/UsageMonthly.cs b/src/Nalu.Api/Data/Models/UsageMonthly.cs new file mode 100644 index 0000000..a2817fb --- /dev/null +++ b/src/Nalu.Api/Data/Models/UsageMonthly.cs @@ -0,0 +1,42 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Nalu.Web.Data.Models; + +/// +/// One document per (api_key + year_month). Primary record for credit-based rate limiting. +/// All counters updated atomically via $inc. Upserted on first call of each month. +/// +public class UsageMonthly +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); + + [BsonElement("api_key")] + public required string ApiKey { get; set; } + + [BsonElement("plan")] + public required string Plan { get; set; } + + /// "YYYY-MM" + [BsonElement("year_month")] + public required string YearMonth { get; set; } + + [BsonElement("total_credits_used")] + public int TotalCreditsUsed { get; set; } + + [BsonElement("total_requests")] + public int TotalRequests { get; set; } + + /// validator_id → credits consumed + [BsonElement("credits_by_validator")] + public Dictionary CreditsByValidator { get; set; } = new(); + + /// validator_id → request count + [BsonElement("requests_by_validator")] + public Dictionary RequestsByValidator { get; set; } = new(); + + [BsonElement("updated_at")] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Nalu.Api/Data/Models/WebhookEvent.cs b/src/Nalu.Api/Data/Models/WebhookEvent.cs new file mode 100644 index 0000000..c054b18 --- /dev/null +++ b/src/Nalu.Api/Data/Models/WebhookEvent.cs @@ -0,0 +1,27 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Nalu.Web.Data.Models; + +/// +/// Stripe webhook idempotency table. One document per Stripe event ID. +/// +public class WebhookEvent +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); + + [BsonElement("stripe_event_id")] + public required string StripeEventId { get; set; } + + [BsonElement("event_type")] + public required string EventType { get; set; } + + /// "processed" | "failed" | "ignored" + [BsonElement("status")] + public string Status { get; set; } = "processed"; + + [BsonElement("processed_at")] + public DateTime ProcessedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Nalu.Api/Data/MongoDbContext.cs b/src/Nalu.Api/Data/MongoDbContext.cs new file mode 100644 index 0000000..c9ed9bb --- /dev/null +++ b/src/Nalu.Api/Data/MongoDbContext.cs @@ -0,0 +1,116 @@ +using MongoDB.Driver; +using Nalu.Web.Data.Models; + +namespace Nalu.Web.Data; + +public class MongoDbContext +{ + private readonly IMongoDatabase? _database; + + public MongoDbContext(IConfiguration configuration, IMongoClient? mongoClient = null) + { + var connectionString = configuration.GetConnectionString("MongoDB"); + + if (mongoClient is not null && !string.IsNullOrWhiteSpace(connectionString)) + { + try + { + var url = MongoUrl.Create(connectionString); + _database = mongoClient.GetDatabase(url.DatabaseName); + IsConnected = true; + } + catch + { + IsConnected = false; + } + } + } + + public bool IsConnected { get; } + + public IMongoCollection Users => + _database!.GetCollection("users"); + + public IMongoCollection ApiKeys => + _database!.GetCollection("api_keys"); + + public IMongoCollection Subscriptions => + _database!.GetCollection("subscriptions"); + + public IMongoCollection UsageDaily => + _database!.GetCollection("usage_daily"); + + public IMongoCollection WebhookEvents => + _database!.GetCollection("webhook_events"); + + public IMongoCollection UsageMonthly => + _database!.GetCollection("usage_monthly"); + + public async Task InitializeAsync() + { + if (!IsConnected) return; + await CreateIndexesAsync(); + } + + private async Task CreateIndexesAsync() + { + // users + await Users.Indexes.CreateManyAsync([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(u => u.Email), + new CreateIndexOptions { Unique = true }), + new CreateIndexModel( + Builders.IndexKeys + .Ascending(u => u.Provider) + .Ascending(u => u.ProviderId), + new CreateIndexOptions { Unique = true }), + ]); + + // api_keys + await ApiKeys.Indexes.CreateManyAsync([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(k => k.Key), + new CreateIndexOptions { Unique = true }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(k => k.UserId)), + ]); + + // subscriptions + await Subscriptions.Indexes.CreateManyAsync([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(s => s.StripeSubscriptionId), + new CreateIndexOptions { Unique = true }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(s => s.UserId)), + ]); + + // usage_daily — compound (api_key + date) unique for atomic $inc upserts + await UsageDaily.Indexes.CreateManyAsync([ + new CreateIndexModel( + Builders.IndexKeys + .Ascending(u => u.ApiKey) + .Ascending(u => u.Date), + new CreateIndexOptions { Unique = true }), + new CreateIndexModel( + Builders.IndexKeys + .Ascending(u => u.ApiKey) + .Ascending(u => u.YearMonth)), + ]); + + // webhook_events — unique on stripe_event_id to prevent duplicate processing + await WebhookEvents.Indexes.CreateManyAsync([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(w => w.StripeEventId), + new CreateIndexOptions { Unique = true }), + ]); + + // usage_monthly — compound (api_key + year_month) unique for atomic $inc upserts + await UsageMonthly.Indexes.CreateManyAsync([ + new CreateIndexModel( + Builders.IndexKeys + .Ascending(u => u.ApiKey) + .Ascending(u => u.YearMonth), + new CreateIndexOptions { Unique = true }), + ]); + } +} diff --git a/src/Nalu.Api/Data/Repositories/ApiKeyRepository.cs b/src/Nalu.Api/Data/Repositories/ApiKeyRepository.cs new file mode 100644 index 0000000..03811f4 --- /dev/null +++ b/src/Nalu.Api/Data/Repositories/ApiKeyRepository.cs @@ -0,0 +1,48 @@ +using MongoDB.Driver; +using Nalu.Web.Data.Models; + +namespace Nalu.Web.Data.Repositories; + +public class ApiKeyRepository(MongoDbContext db) +{ + public async Task FindAsync(string key, CancellationToken ct = default) + { + if (!db.IsConnected) return null; + + return await db.ApiKeys + .Find(k => k.Key == key && k.IsActive) + .FirstOrDefaultAsync(ct); + } + + public async Task TouchAsync(string key, CancellationToken ct = default) + { + if (!db.IsConnected) return; + + await db.ApiKeys.UpdateOneAsync( + k => k.Key == key, + Builders.Update.Set(k => k.LastUsedAt, DateTime.UtcNow), + cancellationToken: ct); + } + + public async Task InsertAsync(ApiKey apiKey, CancellationToken ct = default) + { + await db.ApiKeys.InsertOneAsync(apiKey, cancellationToken: ct); + } + + public async Task> GetByUserAsync(string userId, CancellationToken ct = default) + { + if (!db.IsConnected) return []; + + return await db.ApiKeys + .Find(k => k.UserId == userId && k.IsActive) + .ToListAsync(ct); + } + + public async Task RevokeAsync(string key, CancellationToken ct = default) + { + await db.ApiKeys.UpdateOneAsync( + k => k.Key == key, + Builders.Update.Set(k => k.IsActive, false), + cancellationToken: ct); + } +} diff --git a/src/Nalu.Api/Data/Repositories/SubscriptionRepository.cs b/src/Nalu.Api/Data/Repositories/SubscriptionRepository.cs new file mode 100644 index 0000000..a77ca60 --- /dev/null +++ b/src/Nalu.Api/Data/Repositories/SubscriptionRepository.cs @@ -0,0 +1,46 @@ +using MongoDB.Driver; +using Nalu.Web.Data.Models; + +namespace Nalu.Web.Data.Repositories; + +public class SubscriptionRepository(MongoDbContext db) +{ + public async Task FindByUserAsync(string userId, CancellationToken ct = default) + { + if (!db.IsConnected) return null; + + return await db.Subscriptions + .Find(s => s.UserId == userId && s.Status == "active") + .FirstOrDefaultAsync(ct); + } + + public async Task FindByStripeIdAsync(string stripeSubscriptionId, CancellationToken ct = default) + { + if (!db.IsConnected) return null; + + return await db.Subscriptions + .Find(s => s.StripeSubscriptionId == stripeSubscriptionId) + .FirstOrDefaultAsync(ct); + } + + public async Task UpsertAsync(Subscription subscription, CancellationToken ct = default) + { + subscription.UpdatedAt = DateTime.UtcNow; + + await db.Subscriptions.ReplaceOneAsync( + s => s.StripeSubscriptionId == subscription.StripeSubscriptionId, + subscription, + new ReplaceOptions { IsUpsert = true }, + ct); + } + + public async Task UpdateStatusAsync(string stripeSubscriptionId, string status, CancellationToken ct = default) + { + await db.Subscriptions.UpdateOneAsync( + s => s.StripeSubscriptionId == stripeSubscriptionId, + Builders.Update + .Set(s => s.Status, status) + .Set(s => s.UpdatedAt, DateTime.UtcNow), + cancellationToken: ct); + } +} diff --git a/src/Nalu.Api/Data/Repositories/UsageRepository.cs b/src/Nalu.Api/Data/Repositories/UsageRepository.cs new file mode 100644 index 0000000..7856644 --- /dev/null +++ b/src/Nalu.Api/Data/Repositories/UsageRepository.cs @@ -0,0 +1,68 @@ +using MongoDB.Driver; +using Nalu.Web.Data.Models; + +namespace Nalu.Web.Data.Repositories; + +public class UsageRepository(MongoDbContext db) +{ + /// + /// Atomically increments daily and monthly counters. + /// Returns (dailyCount, monthlyCount) AFTER increment. + /// Returns null if MongoDB is not connected. + /// + public async Task<(int Daily, int Monthly)?> IncrementAsync( + string apiKey, string plan, CancellationToken ct = default) + { + if (!db.IsConnected) return null; + + var now = DateTime.UtcNow; + var date = now.ToString("yyyy-MM-dd"); + var yearMonth = now.ToString("yyyy-MM"); + + // Upsert: if doc exists increment, else create with count=1 + var filter = Builders.Filter.And( + Builders.Filter.Eq(u => u.ApiKey, apiKey), + Builders.Filter.Eq(u => u.Date, date)); + + var update = Builders.Update + .Inc(u => u.DailyCount, 1) + .Inc(u => u.MonthlyCount, 1) + .SetOnInsert(u => u.Plan, plan) + .SetOnInsert(u => u.YearMonth, yearMonth); + + var options = new FindOneAndUpdateOptions + { + IsUpsert = true, + ReturnDocument = ReturnDocument.After + }; + + var doc = await db.UsageDaily.FindOneAndUpdateAsync(filter, update, options, ct); + return (doc.DailyCount, doc.MonthlyCount); + } + + /// + /// Returns (dailyCount, monthlyCount) for the current period without modifying. + /// + public async Task<(int Daily, int Monthly)> GetCurrentUsageAsync( + string apiKey, CancellationToken ct = default) + { + if (!db.IsConnected) return (0, 0); + + var now = DateTime.UtcNow; + var date = now.ToString("yyyy-MM-dd"); + var yearMonth = now.ToString("yyyy-MM"); + + var doc = await db.UsageDaily + .Find(u => u.ApiKey == apiKey && u.Date == date) + .FirstOrDefaultAsync(ct); + + if (doc is null) return (0, 0); + + // Sum all docs in current month for monthly total + var monthlyDocs = await db.UsageDaily + .Find(u => u.ApiKey == apiKey && u.YearMonth == yearMonth) + .ToListAsync(ct); + + return (doc.DailyCount, monthlyDocs.Sum(d => d.DailyCount)); + } +} diff --git a/src/Nalu.Api/Data/Repositories/UserRepository.cs b/src/Nalu.Api/Data/Repositories/UserRepository.cs new file mode 100644 index 0000000..e221eda --- /dev/null +++ b/src/Nalu.Api/Data/Repositories/UserRepository.cs @@ -0,0 +1,61 @@ +using MongoDB.Driver; +using Nalu.Web.Data.Models; + +namespace Nalu.Web.Data.Repositories; + +public class UserRepository(MongoDbContext db) +{ + public async Task FindByEmailAsync(string email, CancellationToken ct = default) + { + if (!db.IsConnected) return null; + + return await db.Users + .Find(u => u.Email == email && u.IsActive) + .FirstOrDefaultAsync(ct); + } + + public async Task FindByProviderAsync(string provider, string providerId, CancellationToken ct = default) + { + if (!db.IsConnected) return null; + + return await db.Users + .Find(u => u.Provider == provider && u.ProviderId == providerId) + .FirstOrDefaultAsync(ct); + } + + public async Task FindByIdAsync(string id, CancellationToken ct = default) + { + if (!db.IsConnected) return null; + + return await db.Users + .Find(u => u.Id == id) + .FirstOrDefaultAsync(ct); + } + + public async Task UpsertAsync(NaluUser user, CancellationToken ct = default) + { + await db.Users.ReplaceOneAsync( + u => u.Provider == user.Provider && u.ProviderId == user.ProviderId, + user, + new ReplaceOptions { IsUpsert = true }, + ct); + } + + public async Task UpdatePlanAsync(string userId, string plan, CancellationToken ct = default) + { + await db.Users.UpdateOneAsync( + u => u.Id == userId, + Builders.Update + .Set(u => u.Plan, plan) + .Set(u => u.LastLoginAt, DateTime.UtcNow), + cancellationToken: ct); + } + + public async Task SetStripeCustomerAsync(string userId, string stripeCustomerId, CancellationToken ct = default) + { + await db.Users.UpdateOneAsync( + u => u.Id == userId, + Builders.Update.Set(u => u.StripeCustomerId, stripeCustomerId), + cancellationToken: ct); + } +} diff --git a/src/Nalu.Api/Data/Repositories/WebhookEventRepository.cs b/src/Nalu.Api/Data/Repositories/WebhookEventRepository.cs new file mode 100644 index 0000000..135a762 --- /dev/null +++ b/src/Nalu.Api/Data/Repositories/WebhookEventRepository.cs @@ -0,0 +1,40 @@ +using MongoDB.Driver; +using Nalu.Web.Data.Models; + +namespace Nalu.Web.Data.Repositories; + +public class WebhookEventRepository(MongoDbContext db) +{ + /// + /// Attempts to record a Stripe event for idempotency. + /// Returns true if inserted (first time seen), false if already processed. + /// + public async Task TryInsertAsync(string stripeEventId, string eventType, CancellationToken ct = default) + { + if (!db.IsConnected) return true; // fallback: allow processing if DB unavailable + + try + { + await db.WebhookEvents.InsertOneAsync(new WebhookEvent + { + StripeEventId = stripeEventId, + EventType = eventType + }, cancellationToken: ct); + + return true; + } + catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + return false; + } + } + + public async Task ExistsAsync(string stripeEventId, CancellationToken ct = default) + { + if (!db.IsConnected) return false; + + return await db.WebhookEvents + .Find(w => w.StripeEventId == stripeEventId) + .AnyAsync(ct); + } +} diff --git a/src/Nalu.Api/Endpoints/ExtractEndpoints.cs b/src/Nalu.Api/Endpoints/ExtractEndpoints.cs new file mode 100644 index 0000000..893acaf --- /dev/null +++ b/src/Nalu.Api/Endpoints/ExtractEndpoints.cs @@ -0,0 +1,232 @@ +using Microsoft.AspNetCore.Mvc; +using Nalu.Web.Models; +using Nalu.Web.Services; +using Nalu.Web.Services.LlmRouter; + +namespace Nalu.Web.Endpoints; + +public static class ExtractEndpoints +{ + public static void MapExtractEndpoints(this WebApplication app) + { + var group = app.MapGroup("/v1/extract") + .RequireAuthorization("ApiKey") + .WithTags("Extract"); + + // ── Deterministic (1 credit) ────────────────────────────────────────── + + group.MapPost("/cpf", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_cpf", ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_cpf", req, ct)); + }) + .WithName("ExtractCpf") + .WithSummary("Extrai e valida CPF") + .WithDescription("Extrai CPF, valida dígitos verificadores (mod 11) e formata XXX.XXX.XXX-XX. Custa 1 crédito.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/cep", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_cep", ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_cep", req, ct)); + }) + .WithName("ExtractCep") + .WithSummary("Extrai CEP e retorna endereço") + .WithDescription("Extrai CEP e enriquece com endereço completo via ViaCEP (fallback BrasilAPI). Custa 1 crédito.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/phone", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_phone_br", ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_phone_br", req, ct)); + }) + .WithName("ExtractPhone") + .WithSummary("Extrai telefone brasileiro com DDD") + .WithDescription("Extrai telefone com DDD e normaliza para (XX) XXXXX-XXXX ou (XX) XXXX-XXXX. Custa 1 crédito.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/email", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_email", ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_email", req, ct)); + }) + .WithName("ExtractEmail") + .WithSummary("Extrai email com correção de typos") + .WithDescription("Extrai email e corrige typos comuns em domínios (gmail, hotmail, outlook). Custa 1 crédito.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/postal-code", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_postal_code", ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_postal_code", req, ct)); + }) + .WithName("ExtractPostalCode") + .WithSummary("Extrai código postal internacional") + .WithDescription("Extrai e normaliza código postal de qualquer país exceto Brasil (use /cep para CEPs). Custa 1 crédito.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/cnpj", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_cnpj", ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_cnpj", req, ct)); + }) + .WithName("ExtractCnpj") + .WithSummary("Extrai e valida CNPJ") + .WithDescription("Extrai CNPJ, valida dígitos verificadores (mod 11) e formata XX.XXX.XXX/XXXX-XX. Custa 1 crédito.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/plate-br", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_plate_br", ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_plate_br", req, ct)); + }) + .WithName("ExtractPlateBr") + .WithSummary("Extrai placa brasileira") + .WithDescription("Suporta Mercosul (ABC1D23) e formato antigo (ABC-1234). Aceita entrada por extenso. Custa 1 crédito.") + .Produces().ProducesProblem(429).WithOpenApi(); + + // ── Light LLM (2 credits) ───────────────────────────────────────────── + + group.MapPost("/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.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/yes-no", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_yes_no", ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_yes_no", req, ct)); + }) + .WithName("ExtractYesNo") + .WithSummary("Detecta sim ou não") + .WithDescription("Retorna extracted_value='true' (sim), 'false' (não) ou null (ambíguo). Custa 2 créditos.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/birthdate", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_birthdate", ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_birthdate", req, ct)); + }) + .WithName("ExtractBirthdate") + .WithSummary("Extrai data de nascimento") + .WithDescription("Extrai data de nascimento em múltiplos formatos e idiomas, calcula idade atual. Custa 2 créditos.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/handoff", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_handoff", ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_handoff", req, ct)); + }) + .WithName("ExtractHandoff") + .WithSummary("Detecta intenção de falar com humano") + .WithDescription("Classifica wants_human (true/false), urgência (low/medium/high) e frustração. Custa 2 créditos.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/cancel-intent", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_cancel_intent", ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_cancel_intent", req, ct)); + }) + .WithName("ExtractCancelIntent") + .WithSummary("Detecta intenção de cancelamento") + .WithDescription("Diferencia cancelamento de serviço, operação atual ou frustração momentânea. Custa 2 créditos.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/company-name", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_company_name", ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_company_name", req, ct)); + }) + .WithName("ExtractCompanyName") + .WithSummary("Extrai nome de empresa") + .WithDescription("Detecta sufixos legais (LTDA, ME, S/A, LLC, Inc, GmbH). Custa 2 créditos.") + .Produces().ProducesProblem(429).WithOpenApi(); + + // ── Heavy LLM (5 credits) — validate_reply ──────────────────────────── + + group.MapPost("/reply", async (HttpContext ctx, + [FromBody] ReplyRequest req, + ReplyService replyService, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_reply", ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + + try + { + var response = await replyService.AnalyzeAsync(req, ct); + return Results.Ok(response); + } + catch (ServiceUnavailableException) + { + ctx.Response.Headers["Retry-After"] = "30"; + return Results.StatusCode(503); + } + }) + .WithName("ExtractReply") + .WithSummary("Analisa contexto conversacional") + .WithDescription( + "Analisa a relação entre a mensagem do agente e a resposta do usuário. " + + "Classifica o tipo (answer, question, counter_proposal, confirmation, rejection, " + + "off_topic, greeting, handoff, cancel, unclear), extrai o significado real e sugere " + + "a próxima fala. Resolve o bug das 48 parcelas vs R$48. Custa 5 créditos.") + .Produces().ProducesProblem(429).ProducesProblem(503).WithOpenApi(); + } +} diff --git a/src/Nalu.Api/Endpoints/ValidatorsEndpoints.cs b/src/Nalu.Api/Endpoints/ValidatorsEndpoints.cs new file mode 100644 index 0000000..13f1212 --- /dev/null +++ b/src/Nalu.Api/Endpoints/ValidatorsEndpoints.cs @@ -0,0 +1,31 @@ +using Nalu.Web.Models; +using Nalu.Web.Services; + +namespace Nalu.Web.Endpoints; + +public static class ValidatorsEndpoints +{ + public static void MapValidatorsEndpoints(this WebApplication app) + { + app.MapGet("/v1/validators", (ValidatorLoader loader) => + { + var validators = loader.LoadAll().Select(v => new ValidatorInfo + { + Id = v.Id, + Endpoint = v.Endpoint, + McpTool = v.McpTool, + Description = !string.IsNullOrEmpty(v.McpDescription) + ? v.McpDescription.Split('.')[0].Trim() + : v.Description, + Version = v.Version, + Languages = v.Languages + }).ToList(); + + return Results.Ok(new { validators }); + }) + .WithName("ListValidators") + .WithSummary("Lista todos os validadores disponíveis") + .WithTags("Validators") + .WithOpenApi(); + } +} diff --git a/src/Nalu.Api/Enrichers/IEnricher.cs b/src/Nalu.Api/Enrichers/IEnricher.cs new file mode 100644 index 0000000..f248e8f --- /dev/null +++ b/src/Nalu.Api/Enrichers/IEnricher.cs @@ -0,0 +1,16 @@ +namespace Nalu.Web.Enrichers; + +public record EnrichmentResult +{ + public string? Value { get; init; } + public bool WasInvalidated { get; init; } + + public static EnrichmentResult Ok(string? value) => new() { Value = value }; + public static EnrichmentResult NotFound() => new() { WasInvalidated = true }; +} + +public interface IEnricher +{ + string Name { get; } + Task EnrichAsync(string value, CancellationToken ct = default); +} diff --git a/src/Nalu.Api/Enrichers/ViaCepEnricher.cs b/src/Nalu.Api/Enrichers/ViaCepEnricher.cs new file mode 100644 index 0000000..3c31f4f --- /dev/null +++ b/src/Nalu.Api/Enrichers/ViaCepEnricher.cs @@ -0,0 +1,98 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace Nalu.Web.Enrichers; + +public class ViaCepEnricher : IEnricher +{ + private readonly HttpClient _http; + private readonly ILogger _logger; + + public string Name => "viacep"; + + public ViaCepEnricher(HttpClient http, ILogger logger) + { + _http = http; + _logger = logger; + } + + public async Task EnrichAsync(string value, CancellationToken ct = default) + { + var cep = Regex.Replace(value, @"\D", ""); + if (cep.Length != 8) return EnrichmentResult.NotFound(); + + // Primary: ViaCEP + try + { + var json = await _http.GetFromJsonAsync( + $"https://viacep.com.br/ws/{cep}/json/", ct); + + // ViaCEP returns {"erro": "true"} (string!) when not found + var erroNode = json?["erro"]; + bool hasError = erroNode is not null && + (erroNode.GetValue() == "true" || (erroNode.GetValueKind() == System.Text.Json.JsonValueKind.True)); + + if (!hasError && json != null) + return BuildResult(cep, json["logradouro"]?.GetValue(), + json["bairro"]?.GetValue(), + json["localidade"]?.GetValue(), + json["uf"]?.GetValue()); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "ViaCEP failed for CEP {Cep}, trying BrasilAPI", cep); + } + + // Fallback: BrasilAPI + try + { + var json = await _http.GetFromJsonAsync( + $"https://brasilapi.com.br/api/cep/v2/{cep}", ct); + + if (json != null) + return BuildResult(cep, + json["street"]?.GetValue(), + json["neighborhood"]?.GetValue(), + json["city"]?.GetValue(), + json["state"]?.GetValue()); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "BrasilAPI also failed for CEP {Cep}", cep); + } + + return EnrichmentResult.NotFound(); + } + + private static EnrichmentResult BuildResult(string cep, string? logradouro, string? bairro, string? cidade, string? estado) + { + logradouro ??= ""; + bairro ??= ""; + cidade ??= ""; + estado ??= ""; + + // Build a human-readable line that degrades gracefully when logradouro is empty + // (neighbourhood/condominium CEPs from ViaCEP often have logradouro == "") + var parts = new List(); + if (!string.IsNullOrWhiteSpace(logradouro)) parts.Add(logradouro); + if (!string.IsNullOrWhiteSpace(bairro)) parts.Add(bairro); + var cityState = string.IsNullOrWhiteSpace(estado) ? cidade : $"{cidade}/{estado}"; + if (!string.IsNullOrWhiteSpace(cityState)) parts.Add(cityState); + var formattedAddress = string.Join(", ", parts); + + var address = new + { + cep = $"{cep[..5]}-{cep[5..]}", + logradouro, + bairro, + cidade, + estado, + formatted_address = formattedAddress + }; + + return EnrichmentResult.Ok(JsonSerializer.Serialize(address)); + } +} diff --git a/src/Nalu.Api/Infrastructure/GroqClient.cs b/src/Nalu.Api/Infrastructure/GroqClient.cs new file mode 100644 index 0000000..10183be --- /dev/null +++ b/src/Nalu.Api/Infrastructure/GroqClient.cs @@ -0,0 +1,105 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Nalu.Web.Infrastructure; + +public record LlmCallResult +{ + public JsonObject? Content { get; init; } + public bool ShouldFallback { get; init; } + public string? Error { get; init; } +} + +public class GroqClient +{ + private readonly HttpClient _http; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public GroqClient(HttpClient http, IConfiguration config, ILogger logger) + { + _http = http; + _config = config; + _logger = logger; + } + + public async Task ChatAsync( + string systemPrompt, + string userMessage, + CancellationToken ct = default) + { + var model = _config["Groq:Model"] ?? "llama-3.3-70b-versatile"; + var maxTokens = _config.GetValue("Groq:MaxTokens", 500); + var temperature = _config.GetValue("Groq:Temperature", 0.1); + + var body = BuildRequestBody(model, systemPrompt, userMessage, maxTokens, temperature); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions") + { + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }; + + var response = await _http.SendAsync(request, ct); + + if (response.StatusCode == HttpStatusCode.TooManyRequests || (int)response.StatusCode >= 500) + { + _logger.LogWarning("Groq returned {Status}, triggering fallback", response.StatusCode); + return new LlmCallResult { ShouldFallback = true }; + } + + if (!response.IsSuccessStatusCode) + { + var err = await response.Content.ReadAsStringAsync(ct); + _logger.LogError("Groq error {Status}: {Body}", response.StatusCode, err); + return new LlmCallResult { Error = $"Groq {response.StatusCode}: {err}" }; + } + + return await ParseChatResponse(response, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Groq call failed unexpectedly"); + return new LlmCallResult { ShouldFallback = true }; + } + } + + internal static object BuildRequestBody( + string model, + string systemPrompt, + string userMessage, + int maxTokens, + double temperature) + { + return new + { + model, + messages = new[] + { + new { role = "system", content = systemPrompt }, + new { role = "user", content = userMessage } + }, + max_tokens = maxTokens, + temperature, + response_format = new { type = "json_object" } + }; + } + + private static async Task ParseChatResponse(HttpResponseMessage response, CancellationToken ct) + { + var json = await response.Content.ReadAsStringAsync(ct); + var doc = JsonNode.Parse(json); + var content = doc?["choices"]?[0]?["message"]?["content"]?.GetValue(); + + if (content is null) + return new LlmCallResult { Error = "Empty content in Groq response" }; + + var obj = JsonNode.Parse(content) as JsonObject; + return obj is not null + ? new LlmCallResult { Content = obj } + : new LlmCallResult { Error = "Groq returned non-object JSON" }; + } +} diff --git a/src/Nalu.Api/Infrastructure/NaluAuthHandler.cs b/src/Nalu.Api/Infrastructure/NaluAuthHandler.cs new file mode 100644 index 0000000..bf23707 --- /dev/null +++ b/src/Nalu.Api/Infrastructure/NaluAuthHandler.cs @@ -0,0 +1,82 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using Nalu.Web.Data.Repositories; + +namespace Nalu.Web.Infrastructure; + +public static class ApiKeyAuthScheme +{ + public const string Name = "ApiKey"; +} + +public record ApiKeyConfig +{ + public required string Key { get; init; } + public required string Plan { get; init; } + public required string Owner { get; init; } +} + +public class NaluAuthHandler : AuthenticationHandler +{ + private readonly ApiKeyRepository _apiKeyRepo; + private readonly List _configKeys; + + public NaluAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ApiKeyRepository apiKeyRepo, + IConfiguration config) + : base(options, logger, encoder) + { + _apiKeyRepo = apiKeyRepo; + _configKeys = config.GetSection("ApiKeys").Get>() ?? []; + } + + protected override async Task HandleAuthenticateAsync() + { + var authHeader = Request.Headers.Authorization.FirstOrDefault(); + + if (authHeader is null || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return AuthenticateResult.NoResult(); + + var key = authHeader["Bearer ".Length..].Trim(); + + string? plan; + string? owner; + + // 1. Try MongoDB first + var dbKey = await _apiKeyRepo.FindAsync(key, Context.RequestAborted); + if (dbKey is not null) + { + plan = dbKey.Plan; + owner = dbKey.Owner; + // Fire-and-forget touch — don't block request for this + _ = _apiKeyRepo.TouchAsync(key); + } + else + { + // 2. Fallback to config (test keys, bootstrap) + var cfgKey = _configKeys.FirstOrDefault(k => k.Key == key); + if (cfgKey is null) + return AuthenticateResult.Fail("Invalid API key"); + + plan = cfgKey.Plan; + owner = cfgKey.Owner; + } + + var claims = new[] + { + new Claim("api_key", key), + new Claim("plan", plan), + new Claim("owner", owner) + }; + + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name)); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return AuthenticateResult.Success(ticket); + } +} diff --git a/src/Nalu.Api/Infrastructure/OpenRouterClient.cs b/src/Nalu.Api/Infrastructure/OpenRouterClient.cs new file mode 100644 index 0000000..6b48bf9 --- /dev/null +++ b/src/Nalu.Api/Infrastructure/OpenRouterClient.cs @@ -0,0 +1,65 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Nalu.Web.Infrastructure; + +public class OpenRouterClient +{ + private readonly HttpClient _http; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public OpenRouterClient(HttpClient http, IConfiguration config, ILogger logger) + { + _http = http; + _config = config; + _logger = logger; + } + + public async Task ChatAsync( + string systemPrompt, + string userMessage, + CancellationToken ct = default) + { + var model = _config["OpenRouter:Model"] ?? "mistralai/mistral-7b-instruct"; + var maxTokens = _config.GetValue("OpenRouter:MaxTokens", 500); + var temperature = _config.GetValue("OpenRouter:Temperature", 0.1); + + var body = GroqClient.BuildRequestBody(model, systemPrompt, userMessage, maxTokens, temperature); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions") + { + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }; + + var response = await _http.SendAsync(request, ct); + + if (!response.IsSuccessStatusCode) + { + var err = await response.Content.ReadAsStringAsync(ct); + _logger.LogError("OpenRouter error {Status}: {Body}", response.StatusCode, err); + return new LlmCallResult { Error = $"OpenRouter {response.StatusCode}: {err}" }; + } + + var json = await response.Content.ReadAsStringAsync(ct); + var doc = JsonNode.Parse(json); + var content = doc?["choices"]?[0]?["message"]?["content"]?.GetValue(); + + if (content is null) + return new LlmCallResult { Error = "Empty content in OpenRouter response" }; + + var obj = JsonNode.Parse(content) as JsonObject; + return obj is not null + ? new LlmCallResult { Content = obj } + : new LlmCallResult { Error = "OpenRouter returned non-object JSON" }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "OpenRouter call failed unexpectedly"); + return new LlmCallResult { Error = ex.Message }; + } + } +} diff --git a/src/Nalu.Api/Mcp/McpServer.cs b/src/Nalu.Api/Mcp/McpServer.cs new file mode 100644 index 0000000..8ed0646 --- /dev/null +++ b/src/Nalu.Api/Mcp/McpServer.cs @@ -0,0 +1,222 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Nalu.Web.Models; +using Nalu.Web.Services; +using Nalu.Web.Services.LlmRouter; + +namespace Nalu.Web.Mcp; + +/// +/// Manual JSON-RPC 2.0 MCP server (Streamable HTTP transport). +/// Handles: initialize, initialized, tools/list, tools/call. +/// Tools are registered dynamically from Validators/*.md files. +/// +public class McpServer +{ + private readonly ValidatorLoader _loader; + private readonly IServiceScopeFactory _scopeFactory; + private readonly CreditService _credits; + private readonly ILogger _logger; + + // Shared JSON schema for standard extraction tools + private static readonly object InputSchema = new + { + type = "object", + properties = new + { + agent_input = new { type = "string", description = "Última mensagem enviada pelo agente ao usuário." }, + user_input = new { type = "string", description = "Resposta do usuário a ser analisada." }, + agent_context = new { type = "string", description = "Contexto do agente (opcional). Ex: negociador de crédito." }, + language = new { type = "string", description = "Idioma da conversa.", @default = "pt-BR" } + }, + required = new[] { "agent_input", "user_input" } + }; + + // Schema for nalu_extract_reply (different field names) + private static readonly object ReplyInputSchema = new + { + type = "object", + properties = new + { + agent_message = new { type = "string", description = "Mensagem enviada pelo agente ao usuário." }, + user_reply = new { type = "string", description = "Resposta do usuário à mensagem do agente." }, + language = new { type = "string", description = "Idioma da conversa.", @default = "pt-BR" } + }, + required = new[] { "agent_message", "user_reply" } + }; + + public McpServer( + ValidatorLoader loader, + IServiceScopeFactory scopeFactory, + CreditService credits, + ILogger logger) + { + _loader = loader; + _scopeFactory = scopeFactory; + _credits = credits; + _logger = logger; + } + + public async Task HandleAsync(HttpContext ctx, CancellationToken ct) + { + // Credit check deferred to per-tool — MCP calls are handled individually below + + // Parse JSON-RPC body + JsonDocument doc; + try + { + doc = await JsonDocument.ParseAsync(ctx.Request.Body, cancellationToken: ct); + } + catch + { + return JsonRpcError(null, -32700, "Parse error"); + } + + using (doc) + { + var root = doc.RootElement; + + var method = root.TryGetProperty("method", out var m) ? m.GetString() : null; + var hasId = root.TryGetProperty("id", out var idProp); + var id = hasId ? ReadId(idProp) : null; + + // Notifications have no id — return 202 Accepted after processing + if (method == "initialized" || (!hasId && method != null)) + { + ctx.Response.StatusCode = 202; + return Results.Empty; + } + + object? result; + try + { + result = method switch + { + "initialize" => HandleInitialize(), + "tools/list" => HandleToolsList(), + "tools/call" => await HandleToolsCallAsync(root, ct), + _ => throw new McpException(-32601, $"Method not found: {method}") + }; + } + catch (McpException ex) + { + return JsonRpcError(id, ex.Code, ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "MCP handler error for method '{Method}'", method); + return JsonRpcError(id, -32603, "Internal error"); + } + + return Results.Json(new { jsonrpc = "2.0", result, id }); + } + } + + // ── Method handlers ─────────────────────────────────────────────────────── + + private static object HandleInitialize() => new + { + protocolVersion = "2024-11-05", + capabilities = new + { + tools = new { listChanged = false } + }, + serverInfo = new { name = "nalu-ai", version = "1.0.0" } + }; + + private object HandleToolsList() + { + var tools = _loader.LoadAll() + .Where(v => !string.IsNullOrEmpty(v.McpTool)) + .Select(v => new + { + name = v.McpTool, + description = v.McpDescription, + inputSchema = v.McpTool == "nalu_extract_reply" ? ReplyInputSchema : InputSchema + }); + + return new { tools }; + } + + private async Task HandleToolsCallAsync(JsonElement root, CancellationToken ct) + { + if (!root.TryGetProperty("params", out var paramsEl)) + throw new McpException(-32602, "Missing params"); + + var toolName = paramsEl.TryGetProperty("name", out var n) ? n.GetString() : null; + if (string.IsNullOrEmpty(toolName)) + throw new McpException(-32602, "Missing params.name"); + + var validator = _loader.LoadAll().FirstOrDefault(v => v.McpTool == toolName); + if (validator is null) + throw new McpException(-32602, $"Unknown tool: {toolName}"); + + var args = paramsEl.TryGetProperty("arguments", out var a) ? a : default; + var language = GetString(args, "language") ?? "pt-BR"; + + using var scope = _scopeFactory.CreateScope(); + string json; + + if (toolName == "nalu_extract_reply") + { + // validate_reply has different input schema + var replyReq = new ReplyRequest + { + AgentMessage = GetString(args, "agent_message") ?? throw new McpException(-32602, "Missing agent_message"), + UserReply = GetString(args, "user_reply") ?? throw new McpException(-32602, "Missing user_reply"), + Language = language + }; + + var replyService = scope.ServiceProvider.GetRequiredService(); + var replyResp = await replyService.AnalyzeAsync(replyReq, ct); + json = JsonSerializer.Serialize(replyResp, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); + } + else + { + var request = new ExtractionRequest + { + AgentInput = GetString(args, "agent_input") ?? throw new McpException(-32602, "Missing agent_input"), + UserInput = GetString(args, "user_input") ?? throw new McpException(-32602, "Missing user_input"), + AgentContext = GetString(args, "agent_context"), + Language = language + }; + + var pipeline = scope.ServiceProvider.GetRequiredService(); + var response = await pipeline.ExecuteAsync(validator.Id, request, ct); + json = JsonSerializer.Serialize(response, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); + } + + return new + { + content = new[] { new { type = "text", text = json } }, + isError = false + }; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static string? GetString(JsonElement el, string key) => + el.ValueKind == JsonValueKind.Object && el.TryGetProperty(key, out var v) + ? v.GetString() + : null; + + private static object? ReadId(JsonElement idProp) => idProp.ValueKind switch + { + JsonValueKind.Number => (object)idProp.GetInt64(), + JsonValueKind.String => idProp.GetString(), + _ => null + }; + + private static IResult JsonRpcError(object? id, int code, string message) => + Results.Json(new + { + jsonrpc = "2.0", + error = new { code, message }, + id + }); + + private sealed class McpException(int code, string message) : Exception(message) + { + public int Code { get; } = code; + } +} diff --git a/src/Nalu.Api/Models/ExtractionRequest.cs b/src/Nalu.Api/Models/ExtractionRequest.cs new file mode 100644 index 0000000..8f3667c --- /dev/null +++ b/src/Nalu.Api/Models/ExtractionRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Nalu.Web.Models; + +public record ExtractionRequest +{ + [JsonPropertyName("agent_input")] + public required string AgentInput { get; init; } + + [JsonPropertyName("user_input")] + public required string UserInput { get; init; } + + [JsonPropertyName("agent_context")] + public string? AgentContext { get; init; } + + [JsonPropertyName("language")] + public string Language { get; init; } = "pt-BR"; +} diff --git a/src/Nalu.Api/Models/ExtractionResponse.cs b/src/Nalu.Api/Models/ExtractionResponse.cs new file mode 100644 index 0000000..53ce6f2 --- /dev/null +++ b/src/Nalu.Api/Models/ExtractionResponse.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace Nalu.Web.Models; + +public record ExtractionResponse +{ + [JsonPropertyName("obtained")] + public bool Obtained { get; init; } + + [JsonPropertyName("extracted_value")] + public string? ExtractedValue { get; init; } + + /// "high" | "medium" | "low" + [JsonPropertyName("confidence")] + public string Confidence { get; init; } = "low"; + + [JsonPropertyName("certain")] + public bool Certain { get; init; } + + [JsonPropertyName("suggestion_to_agent")] + public string? SuggestionToAgent { get; init; } + + [JsonPropertyName("has_suggestion")] + public bool HasSuggestion => SuggestionToAgent is not null; + + /// "scalar" = plain string value | "object" = JSON-as-string, parse before use | null = no value (obtained: false) + [JsonPropertyName("value_format")] + public string? ValueFormat { get; init; } +} diff --git a/src/Nalu.Api/Models/ReplyRequest.cs b/src/Nalu.Api/Models/ReplyRequest.cs new file mode 100644 index 0000000..bc108e8 --- /dev/null +++ b/src/Nalu.Api/Models/ReplyRequest.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Nalu.Web.Models; + +public record ReplyRequest +{ + [JsonPropertyName("agent_message")] + public required string AgentMessage { get; init; } + + [JsonPropertyName("user_reply")] + public required string UserReply { get; init; } + + [JsonPropertyName("language")] + public string Language { get; init; } = "pt-BR"; +} diff --git a/src/Nalu.Api/Models/ReplyResponse.cs b/src/Nalu.Api/Models/ReplyResponse.cs new file mode 100644 index 0000000..e3d096a --- /dev/null +++ b/src/Nalu.Api/Models/ReplyResponse.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Serialization; + +namespace Nalu.Web.Models; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ReplyType +{ + Answer, + Question, + CounterProposal, + Confirmation, + Rejection, + OffTopic, + Greeting, + Handoff, + Cancel, + Unclear +} + +public record ReplyResponse +{ + [JsonPropertyName("obtained")] + public bool Obtained { get; init; } + + [JsonPropertyName("reply_type")] + public ReplyType? ReplyType { get; init; } + + [JsonPropertyName("extracted_value")] + public string? ExtractedValue { get; init; } + + /// "quantity" | "amount" | "date" | "text" | "boolean" | null + [JsonPropertyName("value_type")] + public string? ValueType { get; init; } + + [JsonPropertyName("extracted_meaning")] + public string? ExtractedMeaning { get; init; } + + /// 0.0 – 1.0 float (validate_reply exposes raw confidence — it's a premium endpoint) + [JsonPropertyName("confidence")] + public double Confidence { get; init; } + + [JsonPropertyName("needs_clarification")] + public bool NeedsClarification { get; init; } + + [JsonPropertyName("suggestion_to_agent")] + public string? SuggestionToAgent { get; init; } + + [JsonPropertyName("has_suggestion")] + public bool HasSuggestion => SuggestionToAgent is not null; +} diff --git a/src/Nalu.Api/Models/ValidatorDefinition.cs b/src/Nalu.Api/Models/ValidatorDefinition.cs new file mode 100644 index 0000000..b3ee4ff --- /dev/null +++ b/src/Nalu.Api/Models/ValidatorDefinition.cs @@ -0,0 +1,41 @@ +namespace Nalu.Web.Models; + +public class ValidatorDefinition +{ + public required string Id { get; init; } + public string Type { get; set; } = "extraction"; + public string Version { get; set; } = "1.0"; + public List Languages { get; set; } = []; + public string Endpoint { get; set; } = ""; + public string McpTool { get; set; } = ""; + public string McpDescription { get; set; } = ""; + public string Description { get; set; } = ""; + + // Deterministic rules + public HashSet StopWords { get; set; } = []; + public List RejectPatterns { get; set; } = []; + public List AcceptPatterns { get; set; } = []; + public Dictionary Constraints { get; set; } = []; + + // LLM + public string Prompt { get; set; } = ""; + public List FewShotExamples { get; set; } = []; + + // Processing + public List PostProcessors { get; set; } = []; + public List Enrichers { 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); + + // Localized stop words (keyed by locale) + public Dictionary> LocalizedStopWords { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +public record FewShotExample +{ + public required string AgentInput { get; init; } + public required string UserInput { get; init; } + public required string Output { get; init; } +} diff --git a/src/Nalu.Api/Models/ValidatorInfo.cs b/src/Nalu.Api/Models/ValidatorInfo.cs new file mode 100644 index 0000000..e4b5f74 --- /dev/null +++ b/src/Nalu.Api/Models/ValidatorInfo.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Nalu.Web.Models; + +public record ValidatorInfo +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("endpoint")] + public required string Endpoint { get; init; } + + [JsonPropertyName("mcp_tool")] + public required string McpTool { get; init; } + + [JsonPropertyName("description")] + public required string Description { get; init; } + + [JsonPropertyName("version")] + public required string Version { get; init; } + + [JsonPropertyName("languages")] + public required List Languages { get; init; } +} diff --git a/src/Nalu.Api/Nalu.Web.csproj b/src/Nalu.Api/Nalu.Web.csproj new file mode 100644 index 0000000..82b7cfa --- /dev/null +++ b/src/Nalu.Api/Nalu.Web.csproj @@ -0,0 +1,24 @@ + + + net9.0 + enable + enable + Nalu.Web + Nalu.Web + + + + + Always + + + + + + + + + + + + diff --git a/src/Nalu.Api/Pages/Auth/Callback.cshtml b/src/Nalu.Api/Pages/Auth/Callback.cshtml new file mode 100644 index 0000000..b244d1e --- /dev/null +++ b/src/Nalu.Api/Pages/Auth/Callback.cshtml @@ -0,0 +1,2 @@ +@page "/auth/callback" +@model Nalu.Web.Pages.Auth.CallbackModel diff --git a/src/Nalu.Api/Pages/Auth/Callback.cshtml.cs b/src/Nalu.Api/Pages/Auth/Callback.cshtml.cs new file mode 100644 index 0000000..6bda213 --- /dev/null +++ b/src/Nalu.Api/Pages/Auth/Callback.cshtml.cs @@ -0,0 +1,76 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Nalu.Web.Services; + +namespace Nalu.Web.Pages.Auth; + +public class CallbackModel(UserService userService, ILogger logger) : PageModel +{ + public async Task OnGetAsync(string? returnUrl = null) + { + var result = await HttpContext.AuthenticateAsync("ExternalCookie"); + if (!result.Succeeded) + { + logger.LogWarning("External auth failed: {Error}", result.Failure?.Message); + return RedirectToPage("/Login"); + } + + var principal = result.Principal!; + var provider = result.Properties?.Items[".AuthScheme"] ?? "unknown"; + + var providerId = principal.FindFirstValue(ClaimTypes.NameIdentifier) + ?? principal.FindFirstValue("sub") + ?? throw new InvalidOperationException("No provider ID in claims"); + + var email = principal.FindFirstValue(ClaimTypes.Email) + ?? principal.FindFirstValue("email") + ?? $"{providerId}@{provider}.oauth"; + + var name = principal.FindFirstValue(ClaimTypes.Name) + ?? principal.FindFirstValue("name"); + + var picture = principal.FindFirstValue("picture") + ?? principal.FindFirstValue("urn:github:avatar"); + + var loginResult = await userService.LoginOrCreateAsync( + provider.ToLowerInvariant(), providerId, email, name, picture); + + // Issue site cookie + var claims = new List + { + new(ClaimTypes.NameIdentifier, loginResult.User.Id), + new(ClaimTypes.Email, email), + new(ClaimTypes.Name, name ?? email), + new("plan", loginResult.User.Plan), + new("picture", picture ?? ""), + }; + + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var cookiePrincipal = new ClaimsPrincipal(identity); + + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + cookiePrincipal, + new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.AddDays(30), + }); + + // Delete external cookie + await HttpContext.SignOutAsync("ExternalCookie"); + + // Show API key once for new users + if (loginResult.IsNew) + TempData["NewApiKey"] = loginResult.ApiKey.Key; + + var safeReturn = IsLocalUrl(returnUrl) ? returnUrl : "/painel"; + return Redirect(safeReturn!); + } + + private bool IsLocalUrl(string? url) => + !string.IsNullOrEmpty(url) && url.StartsWith('/') && !url.StartsWith("//"); +} diff --git a/src/Nalu.Api/Pages/Casos/Parcelas48x.cshtml b/src/Nalu.Api/Pages/Casos/Parcelas48x.cshtml new file mode 100644 index 0000000..5d28133 --- /dev/null +++ b/src/Nalu.Api/Pages/Casos/Parcelas48x.cshtml @@ -0,0 +1,168 @@ +@page "/casos/parcelas-48x" +@model Nalu.Web.Pages.Casos.Parcelas48xModel +@{ + ViewData["Title"] = "O bug das 48 parcelas: quando seu chatbot confunde quantidade com valor"; + ViewData["Description"] = "Agente oferece 20x de R$100. Cliente diz 'Bora em 48?'. Bot registra R$48. Acontece todo dia. Saiba como validate_reply resolve por R$ 0,0097."; +} + + +
+
+ +
Caso de uso · validate_reply
+

+ "O bug das 48 parcelas": quando seu chatbot confunde quantidade com valor +

+

+ Acontece todo dia em chatbots de cobrança, vendas e atendimento. E custa vendas. +

+
+
+ + +
+
+

+ Sua empresa treinou um LLM para ser um agente de vendas. Ele oferece parcelamento. + O cliente responde com um número. O bot extrai o número. Erro: o bot confundiu + a quantidade de parcelas que o cliente propôs com um valor em reais. +

+

+ Resultado: proposta errada, cliente frustrado, venda perdida. + E o pior: o bug é silencioso — o bot não sabe que errou. +

+
+
+ + +
+
+

O bug em ação

+
+
BOT TRADICIONAL — DIÁLOGO REAL
+
+
+ Agente: + "Posso parcelar em 20x de R$100. Topa?" +
+
+ Usuário: + "Bora em 48?" +
+
+
❌ Bot extraiu: "48"
+
Interpretou como: R$ 48,00
+
Correto seria: 48 parcelas (contraproposta)
+
+
+
+
+
+ + +
+
+

Por que o LLM erra sozinho

+

+ O LLM genérico recebe apenas a mensagem do usuário: "Bora em 48?". + Sem contexto de que o agente acabou de oferecer PARCELAS, "48" parece um valor monetário + ou simplesmente um número sem significado definido. +

+

+ Mesmo com um sistema prompt bem elaborado, o LLM não foi instruído a analisar + o par agente+usuário como uma unidade semântica — ele vê só + a resposta isolada. +

+
+
+ + +
+
+

Como o NALU AI resolve com validate_reply

+
+
API RESPONSE — validate_reply
+
{
+  "obtained": true,
+  "reply_type": "counter_proposal",
+  "extracted_value": "48",
+  "value_type": "quantity",
+  "extracted_meaning": "Usuário propôs 48 parcelas como
+    contraproposta à oferta de 20 parcelas de R$100",
+  "confidence": 0.95,
+  "needs_clarification": false,
+  "suggestion_to_agent": "O cliente está propondo 48 parcelas
+    em vez das 20 oferecidas. Você pode ajustar a proposta
+    para 48x de um valor menor?"
+}
+
+

+ O validate_reply analisa o PAR agente+usuário e entende que "48" é uma + contraproposta de quantidade de parcelas, não um valor monetário — porque o agente + acabou de oferecer PARCELAS (20x de R$100), não um preço fixo. +

+
+
+ + +
+
+

Custo de resolver

+
+
+
Custo por análise
+
R$ 0,0097
+
5 créditos · plano Starter
+
+
+
Custo de NÃO resolver
+
Venda perdida
+
Cliente frustrado · dados poluídos
+
+
+
+
+ + +
+
+

Código de integração

+
+
cURL
+
curl https://api.naluai.com/v1/extract/reply \
+  -H "Authorization: Bearer SEU_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "agent_message": "Posso parcelar em 20x de R$100. Topa?",
+    "user_reply": "Bora em 48?",
+    "language": "pt-BR"
+  }'
+ +
JavaScript (n8n / Make)
+
const { reply_type, extracted_value, value_type } =
+  await $http.post('https://api.naluai.com/v1/extract/reply', {
+    agent_message: $node['Agent'].json.message,
+    user_reply: $node['User'].json.reply,
+    language: 'pt-BR'
+  }, { headers: { Authorization: 'Bearer ' + $env.NALU_TOKEN } });
+
+// reply_type === 'counter_proposal'
+// extracted_value === '48'
+// value_type === 'quantity'
+
+
+
+ + +
+
+

Teste com seus próprios diálogos

+

O playground é grátis. 50 créditos por dia, sem cadastro.

+ + Testar no playground → + +

Ou criar uma conta e ganhar 3.000 créditos grátis.

+
+
diff --git a/src/Nalu.Api/Pages/Casos/Parcelas48x.cshtml.cs b/src/Nalu.Api/Pages/Casos/Parcelas48x.cshtml.cs new file mode 100644 index 0000000..5cba88f --- /dev/null +++ b/src/Nalu.Api/Pages/Casos/Parcelas48x.cshtml.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Casos; + +public class Parcelas48xModel : PageModel +{ + public void OnGet() { } +} diff --git a/src/Nalu.Api/Pages/Index.cshtml b/src/Nalu.Api/Pages/Index.cshtml new file mode 100644 index 0000000..c6858da --- /dev/null +++ b/src/Nalu.Api/Pages/Index.cshtml @@ -0,0 +1,395 @@ +@page +@model IndexModel +@{ + ViewData["Title"] = "Seu chatbot está gravando 'Bom Dia' como nome"; + ViewData["Description"] = "NALU AI extrai dados reais de diálogos agente/usuário. CPF, nome, CEP, parcelas. A partir de R$ 0,0019 por validação."; +} + + +
+
+
+ 13 validadores · MCP + REST · 3.000 créditos grátis +
+

+ Seu chatbot está gravando
+ "Bom Dia" como nome do cliente. +

+

+ NALU AI extrai o que o usuário realmente disse — nome, CPF, CEP, parcelas — + sem confundir saudação com dado. Integra em 30 segundos. +

+ + + +
    +
  • ✓ 3.000 créditos grátis por mês
  • +
  • ✓ Setup em 30 segundos
  • +
  • ✓ Funciona com n8n, Make, Claude Code, Cursor
  • +
+ +
+ R$ 0,0019 + por validação no plano Starter. +

Menos de 1 centavo para nunca mais gravar "Bom Dia" como nome.

+
+
+
+ + +
+
+

O problema (que todo mundo já teve)

+ + +
+
+
BOT TRADICIONAL
+
+
Agente: Olá! Qual o seu nome?
+
Usuário: Bom dia! Me chamo João Silva
+
❌ Gravou: "Bom Dia Me Chamo João Silva"
+
+
+
+
COM NALU AI (validate_name)
+
+
extracted_value: "João Silva"
+
certain: true
+
confidence: "high"
+
+
Custo: R$ 0,0039 (2 créditos)
+
+
+ + +
+
+
BOT TRADICIONAL
+
+
Agente: Posso parcelar em 20x de R$100. Topa?
+
Usuário: Bora em 48?
+
❌ Bot entendeu: R$ 48,00
+
+
+
+
COM NALU AI (validate_reply) 5 créditos
+
+
reply_type: counter_proposal
+
extracted_value: "48 parcelas"
+
suggestion: "Cliente propõe 48 parcelas..."
+
+
Custo: R$ 0,0097 (5 créditos)
+
+
+ + +
+
+
BOT TRADICIONAL
+
+
Agente: Qual seu CEP?
+
Usuário: É o 01310-100
+
❌ Regex falhou: "É o 01310-100" não é só dígitos
+
+
+
+
COM NALU AI (validate_cep)
+
+
extracted_value: "01310-100"
+
cidade: "São Paulo"
+
bairro: "Bela Vista"
+
+
Custo: R$ 0,0019 (1 crédito)
+
+
+
+
+ + +
+
+

Como funciona

+
+
+
📨
+

1. Envie o diálogo

+

Agente + resposta do usuário. Dois campos. Nada mais.

+
+
+
🧠
+

2. NALU extrai

+

Regras determinísticas primeiro. LLM só quando necessário. Resultado normalizado.

+
+
+
+

3. Use o dado limpo

+

obtained: true + valor validado. Sem regex, sem alucinação.

+
+
+
+
+ + +
+
+ Quadrinho: bot gravando Bom Dia como nome +
+
+ + +
+
+

13 validadores prontos

+

Determinísticos + LLM leve + análise de contexto (70B)

+
+ @foreach (var v in Model.Validators) + { +
+
+
@v.Icon @v.Name
+
+ @if (v.IsNew) + { + NEW + } + @v.Credits cr +
+
+

@v.Description

+
+ } +
+ +
+
+ + +
+
+

Máquina de estados inteligente

+

Validadores podem ser encadeados em fluxos completos.

+
+
+[validate_name]     → confirma identidade              (2 créditos)
+    ↓
+[validate_reply]    → "20x de R$100, topa?"            (5 créditos)
+    ↓ reply_type: counter_proposal
+    → extracted: 48 parcelas
+    → agente avalia se pode oferecer 48x
+    ↓ reply_type: confirmation → prossegue
+    ↓ reply_type: rejection    → oferece alternativa
+    ↓ reply_type: handoff      → transfere para humano
+
+Custo total: 7 créditos = R$ 0,0133 no Starter.
+Mais barato que perder a venda.
+
+
+
+ + +
+
+

+ Com o validate_reply do NALU AI, o bot entende que "48" no contexto + de uma oferta de parcelas é uma contraproposta — não um valor. + Custo por análise: R$ 0,0097. Menos que o cafezinho. +

+ Quadrinho: 48 parcelas vs R$48 +
+
+ + +
+
+

Integração em 30 segundos

+
+
+ + + + +
+ +
+
curl https://api.naluai.com/v1/extract/name \
+  -H "Authorization: Bearer SEU_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "agent_input": "Qual o seu nome?",
+    "user_input": "Bom dia! Me chamo João Silva",
+    "language": "pt-BR"
+  }'
+
+# Resposta:
+# {
+#   "obtained": true,
+#   "extracted_value": "João Silva",
+#   "confidence": "high",
+#   "certain": true
+# }
+
+ + + + + + +
+
+
+ + +
+
+

Preços

+

+ R$ 0,0019 + por validação no plano Starter. +

+ +
+
+
Free
+
R$ 0
+
3.000 créditos/mês
+ Começar grátis +
+
+
Popular
+
Starter
+
R$ 0,0019
+
por validação
+
R$ 29/mês · 15.000 créditos
+ Assinar → +
+
+
Indie
+
R$ 0,0014
+
por validação
+
R$ 69/mês · 50.000 créditos
+ Assinar → +
+
+
Pro
+
R$ 0,0008
+
por validação
+
R$ 199/mês · 250.000 créditos
+ Assinar → +
+
+ + +
+
💡 Fazendo a conta
+

+ Um chatbot de cobrança faz ~500 validações por mês. + Com o plano Starter, isso custa R$ 0,95. + Menos que um café. Para nunca mais perder um cliente + por um bot que confundiu 48 parcelas com R$ 48. +

+

+ Qual o custo de perder uma venda por um erro do bot? +

+
+
+
+ + +
+
+

FAQ

+
+ @foreach (var faq in Model.Faqs) + { +
+
@faq.Q
+
@faq.A
+
+ } +
+
+
+ + +
+
+

Pare de perder dados (e clientes) por regex ruim.

+

3.000 créditos grátis por mês. Sem cartão. Setup em 30 segundos.

+ + Começar grátis → + +

A partir de R$ 0,0019 por validação. Menos que uma gota de café.

+
+
+ +@section Scripts { + +} diff --git a/src/Nalu.Api/Pages/Index.cshtml.cs b/src/Nalu.Api/Pages/Index.cshtml.cs new file mode 100644 index 0000000..92d3570 --- /dev/null +++ b/src/Nalu.Api/Pages/Index.cshtml.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages; + +public class IndexModel : PageModel +{ + public record ValidatorCard(string Icon, string Name, string Description, int Credits, bool IsNew = false); + public record FaqItem(string Q, string A); + + public IReadOnlyList Validators { get; } = + [ + new("🔤", "validate_name", "Extrai nome completo, ignora saudações e títulos.", 2), + new("🆔", "validate_cpf", "Valida CPF com mod 11. Formata XXX.XXX.XXX-XX.", 1), + new("📮", "validate_cep", "Extrai CEP e enriquece com endereço completo via ViaCEP.", 1), + new("📱", "validate_phone", "Extrai telefone com DDD. Valida DDDs ANATEL.", 1), + new("✉️", "validate_email", "Extrai email e corrige typos (gmail→gmail.com).", 1), + new("🌍", "validate_postal_code", "Código postal internacional (não CEP).", 1), + new("☑️", "validate_yes_no", "Detecta sim/não em qualquer idioma e forma indireta.", 2), + new("🎂", "validate_birthdate", "Data de nascimento em qualquer formato. Detecta menores.", 2), + new("🤝", "validate_handoff", "Detecta intenção de falar com humano (urgência 1-3).", 2), + new("🚫", "validate_cancel_intent","Diferencia cancelamento de serviço vs operação atual.", 2), + new("🏢", "validate_cnpj", "Valida CNPJ com mod 11. Formata XX.XXX.XXX/XXXX-XX.", 1), + new("🚗", "validate_plate_br", "Placa Mercosul e formato antigo. Aceita por extenso.", 1), + new("🧠", "validate_reply", "Analisa contexto conversacional. Detecta contrapropostas, handoffs, cancelamentos.", 5, IsNew: true), + ]; + + public IReadOnlyList Faqs { get; } = + [ + new("O que acontece quando meus créditos acabam?", + "Suas chamadas retornam 429 com sugestão de upgrade. Sem cobrança surpresa. Seus dados e chaves continuam intactos."), + new("Por que validate_reply custa 5 créditos?", + "Usa o modelo de IA maior (70B) para analisar o contexto completo do par agente+usuário. Os outros validadores usam regras determinísticas ou modelos menores."), + new("Funciona com n8n e Make?", + "Sim. É uma API REST padrão. Se o seu tool aceita HTTP, funciona com NALU. Também disponível via MCP para Claude Code e Cursor."), + new("Posso usar o MCP com Claude Code?", + "Sim. Adicione o servidor NALU ao Claude Code e chame os validadores como ferramentas nativas. Ver /docs/mcp."), + new("Meus dados ficam armazenados?", + "Não. Os diálogos enviados são usados apenas para processar a chamada e descartados. Não armazenamos o conteúdo das conversas."), + ]; + + public void OnGet() { } +} diff --git a/src/Nalu.Api/Pages/Login.cshtml b/src/Nalu.Api/Pages/Login.cshtml new file mode 100644 index 0000000..800478e --- /dev/null +++ b/src/Nalu.Api/Pages/Login.cshtml @@ -0,0 +1,71 @@ +@page "/login" +@model Nalu.Web.Pages.LoginModel +@{ + ViewData["Title"] = "Entrar — NALU AI"; + ViewData["Description"] = "Entre com sua conta Google, Microsoft ou GitHub para acessar o painel e sua API key."; + Layout = "_Layout"; +} + +
+
+ +
+ + NALU + AI + +

Entre para acessar seu painel e API key

+
+ + +
+

Entrar na sua conta

+ + + +

+ Ao entrar, você concorda com nossos + Termos e + Privacidade. +

+
+ +

+ Novo por aqui? Sua conta é criada automaticamente no primeiro login. +
Você ganha 3.000 créditos grátis. +

+
+
diff --git a/src/Nalu.Api/Pages/Login.cshtml.cs b/src/Nalu.Api/Pages/Login.cshtml.cs new file mode 100644 index 0000000..950d139 --- /dev/null +++ b/src/Nalu.Api/Pages/Login.cshtml.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages; + +public class LoginModel : PageModel +{ + public string? ReturnUrl { get; private set; } + + public void OnGet(string? returnUrl = null) + { + ReturnUrl = returnUrl ?? "/painel"; + } + + public IActionResult OnGetGoogle(string? returnUrl = null) => + Challenge(new Microsoft.AspNetCore.Authentication.AuthenticationProperties + { + RedirectUri = $"/auth/callback?returnUrl={Uri.EscapeDataString(returnUrl ?? "/painel")}" + }, "Google"); + + public IActionResult OnGetMicrosoft(string? returnUrl = null) => + Challenge(new Microsoft.AspNetCore.Authentication.AuthenticationProperties + { + RedirectUri = $"/auth/callback?returnUrl={Uri.EscapeDataString(returnUrl ?? "/painel")}" + }, "Microsoft"); + + public IActionResult OnGetGitHub(string? returnUrl = null) => + Challenge(new Microsoft.AspNetCore.Authentication.AuthenticationProperties + { + RedirectUri = $"/auth/callback?returnUrl={Uri.EscapeDataString(returnUrl ?? "/painel")}" + }, "GitHub"); +} diff --git a/src/Nalu.Api/Pages/Painel/Index.cshtml b/src/Nalu.Api/Pages/Painel/Index.cshtml new file mode 100644 index 0000000..da4898f --- /dev/null +++ b/src/Nalu.Api/Pages/Painel/Index.cshtml @@ -0,0 +1,160 @@ +@page "/painel" +@model Nalu.Web.Pages.Painel.IndexModel +@{ + ViewData["Title"] = "Painel — NALU AI"; + var pct = Model.CreditsLimit > 0 ? Math.Min(100, Model.CreditsUsed * 100 / Model.CreditsLimit) : 0; + var barColor = pct >= 90 ? "bg-red-500" : pct >= 70 ? "bg-amber-500" : "bg-nalu-500"; +} + + +@if (Model.NewApiKey != null) +{ +
+
+
🎉
+

Bem-vindo ao NALU AI!

+

+ Sua conta foi criada com 3.000 créditos grátis. + Guarde sua API key — ela só é exibida uma vez. +

+
+
Sua API Key
+
@Model.NewApiKey
+
+ + +
+
+ +} + +
+ +
+
+ @if (!string.IsNullOrEmpty(Model.UserPicture)) + { + Avatar + } + else + { +
+ @(Model.UserName.Length > 0 ? Model.UserName[0].ToString().ToUpper() : "?") +
+ } +
+
@Model.UserName
+
@Model.UserEmail
+
+
+
+ @Model.Plan + Sair +
+
+ + +
+
+

Uso este mês

+ Reseta em @(new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1).AddMonths(1).ToString("dd/MM")) +
+
+
@Model.CreditsUsed.ToString("N0")
+
/ @Model.CreditsLimit.ToString("N0") créditos
+
+
+
+
+
+ @(Model.CreditsLimit - Model.CreditsUsed) restantes + @pct% +
+ @if (Model.Plan == "free") + { + + } +
+ + +
+
+

API Keys

+
+ + @if (Model.Keys.Count == 0) + { +

Nenhuma key ativa.

+ } + else + { +
+ @foreach (var k in Model.Keys) + { +
+
+
@(k.Label ?? "API Key")
+
+ @(k.Key[..Math.Min(20, k.Key.Length)])… +
+ @if (k.LastUsedAt.HasValue) + { +
Último uso: @k.LastUsedAt.Value.ToString("dd/MM/yyyy HH:mm") UTC
+ } +
+
+ +
+ + +
+
+
+ } +
+ } + + +
+
Como usar
+
curl https://api.naluai.com/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"}'
+
+
+
+ +@section Scripts { + +} diff --git a/src/Nalu.Api/Pages/Painel/Index.cshtml.cs b/src/Nalu.Api/Pages/Painel/Index.cshtml.cs new file mode 100644 index 0000000..e1825fd --- /dev/null +++ b/src/Nalu.Api/Pages/Painel/Index.cshtml.cs @@ -0,0 +1,67 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using MongoDB.Driver; +using Nalu.Web.Data; +using Nalu.Web.Data.Models; +using Nalu.Web.Data.Repositories; +using Nalu.Web.Services; + +namespace Nalu.Web.Pages.Painel; + +[Authorize] +public class IndexModel( + ApiKeyRepository apiKeys, + MongoDbContext db, + IConfiguration config) : PageModel +{ + public List Keys { get; private set; } = []; + public int CreditsUsed { get; private set; } + public int CreditsLimit { get; private set; } + public string? NewApiKey { get; private set; } + public string UserName { get; private set; } = ""; + public string UserEmail { get; private set; } = ""; + public string UserPicture { get; private set; } = ""; + public string Plan { get; private set; } = "free"; + + public async Task OnGetAsync(CancellationToken ct) + { + UserName = User.FindFirstValue(ClaimTypes.Name) ?? ""; + UserEmail = User.FindFirstValue(ClaimTypes.Email) ?? ""; + UserPicture = User.FindFirstValue("picture") ?? ""; + Plan = User.FindFirstValue("plan") ?? "free"; + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + Keys = await apiKeys.GetByUserAsync(userId, ct); + + // Get credit usage from UsageMonthly (same collection CreditService writes to) + if (Keys.Count > 0 && db.IsConnected) + { + var yearMonth = DateTime.UtcNow.ToString("yyyy-MM"); + var monthly = await db.UsageMonthly + .Find(u => u.ApiKey == Keys[0].Key && u.YearMonth == yearMonth) + .FirstOrDefaultAsync(ct); + CreditsUsed = monthly?.TotalCreditsUsed ?? 0; + } + + var planConfig = config.GetSection($"Plans:{Plan}"); + CreditsLimit = planConfig.GetValue("credits_per_month"); + if (CreditsLimit == 0) CreditsLimit = 3000; + + // Welcome modal — shown once + NewApiKey = TempData["NewApiKey"] as string; + } + + public async Task OnPostRevokeAsync(string key, CancellationToken ct) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var userKeys = await apiKeys.GetByUserAsync(userId, ct); + + // Only revoke keys owned by this user + if (userKeys.Any(k => k.Key == key)) + await apiKeys.RevokeAsync(key, ct); + + return RedirectToPage(); + } +} diff --git a/src/Nalu.Api/Pages/Precos.cshtml b/src/Nalu.Api/Pages/Precos.cshtml new file mode 100644 index 0000000..6b06bb3 --- /dev/null +++ b/src/Nalu.Api/Pages/Precos.cshtml @@ -0,0 +1,212 @@ +@page +@model PrecosModel +@{ + ViewData["Title"] = "Preços — a partir de R$ 0,0019 por validação"; + ViewData["Description"] = "Planos NALU AI: Free, Starter (R$29), Indie (R$69), Pro (R$199). A partir de R$ 0,0019 por validação. Menos que uma gota de café."; +} + + +
+
+

Quanto custa consertar seu chatbot?

+
R$ 0,0019
+

por validação no plano Starter.

+
+

Menos que uma gota de café por chamada.

+

Menos que o custo de um SMS.

+

Menos que o prejuízo de UM cliente que desistiu

+

porque o bot não entendeu o que ele disse.

+
+
+
+ + +
+
+
+ + +
+
Free
+
Para testar e projetos pessoais
+
R$ 0
+
para sempre
+
    +
  • ✓ 3.000 créditos/mês
  • +
  • ✓ 13 validadores
  • +
  • ✓ Playground
  • +
  • ✓ Docs completa
  • +
  • – Email: sem suporte
  • +
+ + Começar grátis + +
+ + +
+
Mais popular
+
Starter
+
Para startups e MVPs
+
R$ 0,0019
+
por validação
+
R$ 29/mês · 15.000 créditos
+
    +
  • ✓ 15.000 créditos/mês
  • +
  • ✓ Tudo do Free
  • +
  • ✓ Dashboard
  • +
  • ✓ Email 72h
  • +
+ + Assinar → + +
+ + +
+
Indie
+
Para produtos em crescimento
+
R$ 0,0014
+
por validação
+
R$ 69/mês · 50.000 créditos
+
    +
  • ✓ 50.000 créditos/mês
  • +
  • ✓ Tudo do Starter
  • +
  • ✓ Email 24h
  • +
  • ✓ Priority queue
  • +
+ + Assinar → + +
+ + +
+
Pro
+
Para produtos em escala
+
R$ 0,0008
+
por validação
+
R$ 199/mês · 250.000 créditos
+
    +
  • ✓ 250.000 créditos/mês
  • +
  • ✓ Tudo do Indie
  • +
  • ✓ SLA 99%
  • +
  • ✓ Suporte 8h
  • +
+ + Assinar → + +
+
+
+
+ + +
+
+

Quanto custa cada validador?

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ValidadorCréditosStarterPro
CPF, CEP, CNPJ, email, telefone, placa, CEP internacional1R$ 0,0019R$ 0,0008
Nome, sim/não, data nascimento, handoff, cancelamento, empresa2R$ 0,0039R$ 0,0016
+ 🧠 validate_reply (análise de contexto) + Premium + 5R$ 0,0097R$ 0,0040
+
+
+
+ + +
+
+

Quanto rende cada plano?

+ +
+
💡 Exemplo real: chatbot de cobrança via WhatsApp
+

Validações típicas por mês:

+
    +
  • • 200 extrações de nome (×2 cred = 400 cred)
  • +
  • • 200 validações de CPF (×1 cred = 200 cred)
  • +
  • • 100 análises de contexto reply (×5 cred = 500 cred)
  • +
  • • Total: 1.100 créditos = cabe no Free!
  • +
+

+ Cresceu? Com o Starter (R$ 29/mês), o mesmo bot atende 10× mais clientes + pelo custo de R$ 0,0019 por validação. +

+
+ +
+

+ O café que você tomou hoje custou mais que 1.000 validações. +

+
+
+
+ + +
+
+

Dúvidas sobre preço

+
+
+
E se meus créditos acabarem?
+
Chamadas retornam 429 com sugestão de upgrade. Sem cobrança surpresa. Seus dados e chaves continuam intactos.
+
+
+
Por que validate_reply custa 5 créditos?
+
Usa modelo de IA maior (70B) e analisa o contexto completo do par agente+usuário. Os outros usam regras determinísticas ou modelos leves.
+
+
+
Posso mudar de plano?
+
Sim, a qualquer momento. Upgrade é imediato. Downgrade no próximo ciclo de cobrança.
+
+
+
Aceita Pix ou boleto?
+
Aceitamos cartão de crédito e débito via Stripe. Pix em breve.
+
+
+
Tem desconto anual?
+
Em breve (fase 2). Cadastre-se para ser notificado.
+
+
+
+
+ + +
+
+

Comece com 3.000 créditos grátis

+

Sem cartão. Sem prazo. Setup em 30 segundos.

+ + Criar conta grátis → + +
+
diff --git a/src/Nalu.Api/Pages/Precos.cshtml.cs b/src/Nalu.Api/Pages/Precos.cshtml.cs new file mode 100644 index 0000000..8fca55f --- /dev/null +++ b/src/Nalu.Api/Pages/Precos.cshtml.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages; + +public class PrecosModel : PageModel +{ + public void OnGet() { } +} diff --git a/src/Nalu.Api/Pages/Validadores/Reply.cshtml b/src/Nalu.Api/Pages/Validadores/Reply.cshtml new file mode 100644 index 0000000..51464b8 --- /dev/null +++ b/src/Nalu.Api/Pages/Validadores/Reply.cshtml @@ -0,0 +1,144 @@ +@page "/validadores/reply" +@model Nalu.Web.Pages.Validadores.ReplyModel +@{ + ViewData["Title"] = "validate_reply — Análise de contexto conversacional"; + ViewData["Description"] = "validate_reply analisa o par agente+usuário e detecta contrapropostas, evasivas, handoffs e ambiguidades. Resolve o bug das 48 parcelas vs R$48. 5 créditos."; +} + + +
+
+ +
+
🧠
+
+
+

validate_reply

+ ⭐ Premium · 5 créditos + NEW +
+

Análise de contexto conversacional.

+
+
+
+
+ + +
+
+

O que faz

+

+ Analisa a relação entre a mensagem do agente e a resposta do usuário. + Classifica o tipo de resposta, extrai o significado real considerando o contexto, + e sugere a próxima fala do agente. +

+ +

Resolve problemas como

+
    +
  • "Bora em 48?" → 48 parcelas, não R$48
  • +
  • "É 200" → número do endereço, não R$200
  • +
  • "Prefiro quinta" → quinta-feira, não 5 de algo
  • +
  • "Não aguento mais" → handoff detectado
  • +
  • "Quero o de cima" → upgrade de plano
  • +
+
+
+ + +
+
+

10 tipos de resposta detectados

+
+ @foreach (var rt in Model.ReplyTypes) + { +
+
+ @rt.Type +
+
@rt.Label
+
@rt.Example
+
+
+
+ } +
+
+
+ + +
+
+

Input / Output

+
+
+
Input
+
{
+  "agent_message": "Posso parcelar em 20x de R$100. Topa?",
+  "user_reply": "Bora em 48?",
+  "language": "pt-BR"
+}
+

Nota: este validador usa agent_message + user_reply em vez de agent_input + user_input.

+
+
+
Output
+
{
+  "obtained": true,
+  "reply_type": "counter_proposal",
+  "extracted_value": "48",
+  "value_type": "quantity",
+  "extracted_meaning": "Usuário propôs 48 parcelas como
+  contraproposta à oferta de 20 parcelas",
+  "confidence": 0.95,
+  "needs_clarification": false,
+  "suggestion_to_agent": "O cliente está propondo 48
+  parcelas em vez das 20 oferecidas. Deseja ajustar?"
+}
+
+
+
+
+ + +
+
+

Exemplos

+
+ @foreach (var ex in Model.Examples) + { +
+
+
@ex.Label
+ @ex.ExpectedType +
+
+
Agente: @ex.AgentMessage
+
Usuário: @ex.UserReply
+
+ @if (ex.Note != null) + { +
💡 @ex.Note
+ } +
+ } +
+
+
+ + +
+
+
+
5 créditos por chamada
+
+

No plano Starter: R$ 0,0097 por análise.

+

No plano Pro: R$ 0,0040 por análise.

+

Menos de 1 centavo para entender o que o cliente realmente quis dizer.

+
+ + Testar no playground → + +
+
+
diff --git a/src/Nalu.Api/Pages/Validadores/Reply.cshtml.cs b/src/Nalu.Api/Pages/Validadores/Reply.cshtml.cs new file mode 100644 index 0000000..989139a --- /dev/null +++ b/src/Nalu.Api/Pages/Validadores/Reply.cshtml.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Validadores; + +public class ReplyModel : PageModel +{ + public record ReplyTypeCard(string Type, string Label, string Example); + public record ExampleCard(string Label, string AgentMessage, string UserReply, string ExpectedType, string? Note = null); + + public IReadOnlyList ReplyTypes { get; } = + [ + new("answer", "Resposta direta", "\"Sim, meu nome é João Silva\""), + new("question", "Pergunta de volta", "\"Que horas vocês atendem?\""), + new("counter_proposal", "Contraproposta", "\"Bora em 48?\" (após oferta de 20x)"), + new("confirmation", "Confirmação positiva", "\"Pode mandar\", \"Ok\", \"Bora\""), + new("rejection", "Rejeição", "\"Não obrigado\", \"Não quero\""), + new("off_topic", "Fora do tópico", "\"Vocês vendem seguro?\" (após pedir CPF)"), + new("greeting", "Saudação", "\"Bom dia!\", \"Oi\""), + new("handoff", "Quer falar com humano", "\"Prefiro falar com alguém de verdade\""), + new("cancel", "Quer cancelar", "\"Para, cancela isso\", \"Quero sair\""), + new("unclear", "Ambíguo", "\"Acho que sim\" (muito dúbio)"), + ]; + + public IReadOnlyList Examples { get; } = + [ + new("Contraproposta de parcelas", + "Posso parcelar em 20x de R$100. Topa?", + "Bora em 48?", + "counter_proposal", + "48 é quantidade de parcelas, não R$48"), + new("Confirmação simples", + "Seu pedido ficou em R$250. Confirma?", + "Pode mandar", + "confirmation"), + new("Pergunta de volta", + "Quer agendar pra terça?", + "Que horas vocês atendem?", + "question"), + new("Número ambíguo em contexto de endereço", + "Confirma o endereço Rua das Flores, 100?", + "É 200", + "answer", + "200 é o número do endereço, não R$200"), + new("Quer falar com humano", + "Posso te ajudar a escolher o plano ideal.", + "Prefiro falar com alguém de verdade", + "handoff"), + new("Rejeição educada", + "Temos um plano premium por R$99/mês. Interesse?", + "Não, obrigado. Tá caro pra mim", + "rejection"), + new("Off-topic", + "Qual seu CPF para consulta?", + "Vocês vendem seguro de carro?", + "off_topic"), + new("Saudação sem conteúdo", + "Boa tarde! Em que posso ajudar?", + "Boa tarde!", + "greeting"), + ]; + + public void OnGet() { } +} diff --git a/src/Nalu.Api/Pages/_Layout.cshtml b/src/Nalu.Api/Pages/_Layout.cshtml new file mode 100644 index 0000000..f2004cb --- /dev/null +++ b/src/Nalu.Api/Pages/_Layout.cshtml @@ -0,0 +1,116 @@ + + + + + + @ViewData["Title"] – NALU AI + + + + + + + @await RenderSectionAsync("Head", required: false) + + + + + + + @RenderBody() + + + + + @await RenderSectionAsync("Scripts", required: false) + + diff --git a/src/Nalu.Api/Pages/_ViewImports.cshtml b/src/Nalu.Api/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..880e300 --- /dev/null +++ b/src/Nalu.Api/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using Nalu.Web.Pages +@using System.Security.Claims +@namespace Nalu.Web.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/Nalu.Api/Pages/_ViewStart.cshtml b/src/Nalu.Api/Pages/_ViewStart.cshtml new file mode 100644 index 0000000..820a2f6 --- /dev/null +++ b/src/Nalu.Api/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/src/Nalu.Api/PostProcessors/CalculateAge.cs b/src/Nalu.Api/PostProcessors/CalculateAge.cs new file mode 100644 index 0000000..467c54f --- /dev/null +++ b/src/Nalu.Api/PostProcessors/CalculateAge.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Nalu.Web.PostProcessors; + +/// Reads the JSON produced by ParseDate, adds "age" and "minor" fields. +/// Sets SuggestionKeyOverride = "when_minor" when age < 18. +public class CalculateAge : IPostProcessor +{ + public string Name => "calculate_age"; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return ProcessorResult.Invalid("Nenhuma data para calcular a idade"); + + JsonObject obj; + try + { + obj = JsonNode.Parse(value)?.AsObject() + ?? throw new InvalidOperationException(); + } + catch + { + return ProcessorResult.Invalid("Formato interno de data inválido"); + } + + if (!DateTime.TryParse(obj["date"]?.GetValue(), out var birth)) + return ProcessorResult.Invalid("Campo 'date' ausente ou inválido no JSON de data"); + + var age = ParseDate.ComputeAge(birth); + var minor = age < 18; + + obj["age"] = age; + obj["minor"] = minor; + + var json = obj.ToJsonString(); + + return minor + ? ProcessorResult.WithOverride(json, "when_minor") + : ProcessorResult.Ok(json); + } +} diff --git a/src/Nalu.Api/PostProcessors/CapitalizeProperName.cs b/src/Nalu.Api/PostProcessors/CapitalizeProperName.cs new file mode 100644 index 0000000..3ae8cf4 --- /dev/null +++ b/src/Nalu.Api/PostProcessors/CapitalizeProperName.cs @@ -0,0 +1,33 @@ +using System.Globalization; +using Nalu.Web.PostProcessors; + +namespace Nalu.Web.PostProcessors; + +public class CapitalizeProperName : IPostProcessor +{ + public string Name => "capitalize_proper_name"; + + private static readonly HashSet LowercaseWords = new(StringComparer.OrdinalIgnoreCase) + { + "de", "da", "do", "das", "dos", "e", "em", "na", "no", "nas", "nos", "van", "von", "del" + }; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Ok(value); + + var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var result = new List(words.Length); + + for (int i = 0; i < words.Length; i++) + { + var word = words[i]; + if (i > 0 && LowercaseWords.Contains(word)) + result.Add(word.ToLowerInvariant()); + else + result.Add(CultureInfo.InvariantCulture.TextInfo.ToTitleCase(word.ToLowerInvariant())); + } + + return ProcessorResult.Ok(string.Join(' ', result)); + } +} diff --git a/src/Nalu.Api/PostProcessors/CorrectEmailTypos.cs b/src/Nalu.Api/PostProcessors/CorrectEmailTypos.cs new file mode 100644 index 0000000..4198307 --- /dev/null +++ b/src/Nalu.Api/PostProcessors/CorrectEmailTypos.cs @@ -0,0 +1,57 @@ +namespace Nalu.Web.PostProcessors; + +public class CorrectEmailTypos : IPostProcessor +{ + public string Name => "correct_email_typos"; + + private static readonly Dictionary DomainFixes = new(StringComparer.OrdinalIgnoreCase) + { + // Gmail + { "gmail.com.br", "gmail.com" }, + { "gamil.com", "gmail.com" }, + { "gmaill.com", "gmail.com" }, + { "gnail.com", "gmail.com" }, + { "gmail.con", "gmail.com" }, + { "gmal.com", "gmail.com" }, + // Hotmail + { "hotmal.com", "hotmail.com" }, + { "hotmial.com", "hotmail.com" }, + { "hotmail.con", "hotmail.com" }, + { "homail.com", "hotmail.com" }, + { "htmail.com", "hotmail.com" }, + // Outlook + { "outlok.com", "outlook.com" }, + { "outllok.com", "outlook.com" }, + { "outlook.con", "outlook.com" }, + // Yahoo + { "yaho.com", "yahoo.com" }, + { "yahooo.com", "yahoo.com" }, + { "yahoo.con", "yahoo.com" }, + // iCloud + { "iclod.com", "icloud.com" }, + { "icould.com", "icloud.com" }, + // UOL/BOL + { "bol.com.be", "bol.com.br" }, + { "uol.com.be", "uol.com.br" }, + }; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Ok(value); + + var email = value.Trim().ToLowerInvariant(); + var atIdx = email.IndexOf('@'); + if (atIdx < 0) return ProcessorResult.Ok(email); + + var local = email[..atIdx]; + var domain = email[(atIdx + 1)..]; + + if (DomainFixes.TryGetValue(domain, out var corrected)) + { + var fixedEmail = $"{local}@{corrected}"; + return ProcessorResult.Corrected(fixedEmail, value.Trim()); + } + + return ProcessorResult.Ok(email); + } +} diff --git a/src/Nalu.Api/PostProcessors/FormatCep.cs b/src/Nalu.Api/PostProcessors/FormatCep.cs new file mode 100644 index 0000000..e3a20dd --- /dev/null +++ b/src/Nalu.Api/PostProcessors/FormatCep.cs @@ -0,0 +1,23 @@ +using System.Text.RegularExpressions; + +namespace Nalu.Web.PostProcessors; + +public class FormatCep : IPostProcessor +{ + public string Name => "format_cep"; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Invalid("CEP não informado"); + + var digits = Regex.Replace(value, @"\D", ""); + + if (digits.Length != 8) + return ProcessorResult.Invalid("CEP deve ter 8 dígitos"); + + if (digits == "00000000") + return ProcessorResult.Invalid("CEP inválido"); + + return ProcessorResult.Ok($"{digits[..5]}-{digits[5..]}"); + } +} diff --git a/src/Nalu.Api/PostProcessors/FormatPhone.cs b/src/Nalu.Api/PostProcessors/FormatPhone.cs new file mode 100644 index 0000000..9205e12 --- /dev/null +++ b/src/Nalu.Api/PostProcessors/FormatPhone.cs @@ -0,0 +1,69 @@ +using System.Text.RegularExpressions; + +namespace Nalu.Web.PostProcessors; + +public class FormatPhone : IPostProcessor +{ + public string Name => "format_phone"; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Invalid("Telefone não informado"); + + var digits = Regex.Replace(value, @"\D", ""); + + // Strip Brazilian country code +55 + if (digits.StartsWith("55") && digits.Length is 12 or 13) + digits = digits[2..]; + + return digits.Length switch + { + 11 => FormatMobile(digits), + 10 => FormatLandline(digits), + _ => ProcessorResult.Invalid("Telefone inválido (esperado 10 ou 11 dígitos com DDD)") + }; + } + + private static ProcessorResult FormatMobile(string digits) + { + var ddd = digits[..2]; + if (!IsValidDdd(ddd)) return ProcessorResult.Invalid($"DDD inválido: {ddd}"); + if (digits[2] != '9') return ProcessorResult.Invalid("Celular deve começar com 9 após o DDD"); + return ProcessorResult.Ok($"({ddd}) {digits[2..7]}-{digits[7..]}"); + } + + private static ProcessorResult FormatLandline(string digits) + { + var ddd = digits[..2]; + if (!IsValidDdd(ddd)) return ProcessorResult.Invalid($"DDD inválido: {ddd}"); + return ProcessorResult.Ok($"({ddd}) {digits[2..6]}-{digits[6..]}"); + } + + // Complete list of valid Brazilian DDDs per ANATEL (Plano de Numeração) + // Source: anatel.gov.br/outorga/plano-de-numeracao + private static readonly HashSet ValidDdds = + [ + // São Paulo + "11","12","13","14","15","16","17","18","19", + // Rio de Janeiro, Espírito Santo + "21","22","24","27","28", + // Minas Gerais + "31","32","33","34","35","37","38", + // Paraná + "41","42","43","44","45","46", + // Santa Catarina + "47","48","49", + // Rio Grande do Sul + "51","53","54","55", + // Distrito Federal / Goiás / Tocantins / MT / MS / AC / RO + "61","62","63","64","65","66","67","68","69", + // Bahia, Sergipe + "71","73","74","75","77","79", + // PE, AL, PB, RN, CE, PI + "81","82","83","84","85","86","87","88","89", + // PA, AM, RR, AP, MA, RO (norte) + "91","92","93","94","95","96","97","98","99", + ]; + + private static bool IsValidDdd(string ddd) => ValidDdds.Contains(ddd); +} diff --git a/src/Nalu.Api/PostProcessors/FormatPlate.cs b/src/Nalu.Api/PostProcessors/FormatPlate.cs new file mode 100644 index 0000000..d3bfa5f --- /dev/null +++ b/src/Nalu.Api/PostProcessors/FormatPlate.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Nalu.Web.PostProcessors; + +/// Normalizes Brazilian vehicle plates (Mercosul and old format). +/// Returns JSON: {"plate":"ABC1D23","format":"mercosul"} or {"plate":"ABC1234","format":"old"}. +public class FormatPlate : IPostProcessor +{ + public string Name => "format_plate"; + + // Mercosul: AAA1B23 + private static readonly Regex Mercosul = new(@"^([A-Z]{3})(\d)([A-Z])(\d{2})$", RegexOptions.Compiled); + // Old: AAA1234 + private static readonly Regex Old = new(@"^([A-Z]{3})(\d{4})$", RegexOptions.Compiled); + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return ProcessorResult.Invalid("Placa não informada"); + + // Normalize: uppercase, strip non-alphanumeric + var normalized = Regex.Replace(value.ToUpperInvariant(), @"[^A-Z0-9]", ""); + + if (Mercosul.IsMatch(normalized)) + { + var json = JsonSerializer.Serialize(new { plate = normalized, format = "mercosul" }); + return ProcessorResult.Ok(json); + } + + if (Old.IsMatch(normalized)) + { + var json = JsonSerializer.Serialize(new { plate = normalized, format = "old" }); + return ProcessorResult.Ok(json); + } + + return ProcessorResult.Invalid($"Placa inválida: '{normalized}'. Use ABC1D23 (Mercosul) ou ABC1234 (antiga)."); + } +} diff --git a/src/Nalu.Api/PostProcessors/IPostProcessor.cs b/src/Nalu.Api/PostProcessors/IPostProcessor.cs new file mode 100644 index 0000000..e75fa22 --- /dev/null +++ b/src/Nalu.Api/PostProcessors/IPostProcessor.cs @@ -0,0 +1,26 @@ +namespace Nalu.Web.PostProcessors; + +public record ProcessorResult +{ + public string? Value { get; init; } + public bool IsValid { get; init; } = true; + public bool WasCorrected { get; init; } + public string? OriginalValue { get; init; } + public string? InvalidReason { get; init; } + + /// Overrides the suggestion template key selected by SuggestionBuilder (e.g. "when_minor"). + public string? SuggestionKeyOverride { get; init; } + + public static ProcessorResult Ok(string? value) => new() { Value = value }; + public static ProcessorResult Invalid(string? reason = null) => new() { IsValid = false, InvalidReason = reason }; + public static ProcessorResult Corrected(string value, string original) => + new() { Value = value, WasCorrected = true, OriginalValue = original }; + public static ProcessorResult WithOverride(string value, string suggestionKey) => + new() { Value = value, SuggestionKeyOverride = suggestionKey }; +} + +public interface IPostProcessor +{ + string Name { get; } + ProcessorResult Process(string? value); +} diff --git a/src/Nalu.Api/PostProcessors/NormalizePostalCode.cs b/src/Nalu.Api/PostProcessors/NormalizePostalCode.cs new file mode 100644 index 0000000..99b33c0 --- /dev/null +++ b/src/Nalu.Api/PostProcessors/NormalizePostalCode.cs @@ -0,0 +1,31 @@ +using System.Text.RegularExpressions; + +namespace Nalu.Web.PostProcessors; + +public class NormalizePostalCode : IPostProcessor +{ + public string Name => "normalize_postal_code"; + + // Accepts: 3–10 alphanumeric chars, optionally split by a single space or hyphen + private static readonly Regex ValidPattern = + new(@"^[A-Z0-9]{2,}([\s\-][A-Z0-9]{2,})?$", RegexOptions.Compiled); + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return ProcessorResult.Invalid("Código postal vazio"); + + // Uppercase + collapse whitespace + var normalized = Regex.Replace(value.Trim().ToUpperInvariant(), @"\s+", " "); + + // Reject all-zeros + if (Regex.IsMatch(normalized, @"^0+$")) + return ProcessorResult.Invalid("Código postal inválido (todos zeros)"); + + // Must contain at least one digit or letter group matching postal pattern + if (!ValidPattern.IsMatch(normalized)) + return ProcessorResult.Invalid($"Formato de código postal não reconhecido: {normalized}"); + + return ProcessorResult.Ok(normalized); + } +} diff --git a/src/Nalu.Api/PostProcessors/ParseDate.cs b/src/Nalu.Api/PostProcessors/ParseDate.cs new file mode 100644 index 0000000..636245b --- /dev/null +++ b/src/Nalu.Api/PostProcessors/ParseDate.cs @@ -0,0 +1,115 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Nalu.Web.PostProcessors; + +/// Parses a date string in multiple formats and returns JSON: {"date":"YYYY-MM-DD","formatted":"DD/MM/YYYY"}. +/// Validates: not in future, not > 130 years ago, real calendar date. +public class ParseDate : IPostProcessor +{ + public string Name => "parse_date"; + + private static readonly string[] Formats = + [ + "yyyy-MM-dd", // ISO + "dd/MM/yyyy", "dd-MM-yyyy", "dd.MM.yyyy", + "MM/dd/yyyy", "MM-dd-yyyy", // en-US (tried after DD/MM) + "dd/MM/yy", "MM/dd/yy", + "d/M/yyyy", "d-M-yyyy", + "d/M/yy" + ]; + + // Month name tables + private static readonly Dictionary MonthsPt = new(StringComparer.OrdinalIgnoreCase) + { + {"janeiro",1},{"fevereiro",2},{"março",3},{"marco",3},{"abril",4}, + {"maio",5},{"junho",6},{"julho",7},{"agosto",8},{"setembro",9}, + {"outubro",10},{"novembro",11},{"dezembro",12} + }; + private static readonly Dictionary MonthsEn = new(StringComparer.OrdinalIgnoreCase) + { + {"january",1},{"february",2},{"march",3},{"april",4},{"may",5}, + {"june",6},{"july",7},{"august",8},{"september",9},{"october",10}, + {"november",11},{"december",12}, + {"jan",1},{"feb",2},{"mar",3},{"apr",4},{"jun",6},{"jul",7}, + {"aug",8},{"sep",9},{"oct",10},{"nov",11},{"dec",12} + }; + private static readonly Dictionary MonthsEs = new(StringComparer.OrdinalIgnoreCase) + { + {"enero",1},{"febrero",2},{"marzo",3},{"abril",4},{"mayo",5}, + {"junio",6},{"julio",7},{"agosto",8},{"septiembre",9}, + {"octubre",10},{"noviembre",11},{"diciembre",12} + }; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return ProcessorResult.Invalid("Data não informada"); + + var date = TryParseAll(value.Trim()); + if (date is null) + return ProcessorResult.Invalid($"Formato de data não reconhecido: {value}"); + + if (date.Value > DateTime.Today) + return ProcessorResult.Invalid("Data de nascimento não pode ser no futuro"); + + var age = ComputeAge(date.Value); + if (age > 130) + return ProcessorResult.Invalid("Data de nascimento improvável (mais de 130 anos atrás)"); + + var json = JsonSerializer.Serialize(new + { + date = date.Value.ToString("yyyy-MM-dd"), + formatted = date.Value.ToString("dd/MM/yyyy") + }); + + return ProcessorResult.Ok(json); + } + + private static DateTime? TryParseAll(string input) + { + // 1. Try standard formats + if (DateTime.TryParseExact(input, Formats, CultureInfo.InvariantCulture, + DateTimeStyles.None, out var dt)) + return dt; + + // 2. Try written month — pt: "15 de março de 1990" + var mPt = Regex.Match(input, + @"(\d{1,2})\s+de\s+(\w+)\s+de\s+(\d{2,4})", RegexOptions.IgnoreCase); + if (mPt.Success && MonthsPt.TryGetValue(mPt.Groups[2].Value, out var moPt)) + return BuildDate(int.Parse(mPt.Groups[1].Value), moPt, ExpandYear(int.Parse(mPt.Groups[3].Value))); + + // 3. en: "March 15, 1990" / "March 15 1990" + var mEn = Regex.Match(input, + @"(\w+)\s+(\d{1,2})[,\s]+(\d{2,4})", RegexOptions.IgnoreCase); + if (mEn.Success && MonthsEn.TryGetValue(mEn.Groups[1].Value, out var moEn)) + return BuildDate(int.Parse(mEn.Groups[2].Value), moEn, ExpandYear(int.Parse(mEn.Groups[3].Value))); + + // 4. es: "15 de marzo de 1990" + var mEs = Regex.Match(input, + @"(\d{1,2})\s+de\s+(\w+)\s+de\s+(\d{2,4})", RegexOptions.IgnoreCase); + if (mEs.Success && MonthsEs.TryGetValue(mEs.Groups[2].Value, out var moEs)) + return BuildDate(int.Parse(mEs.Groups[1].Value), moEs, ExpandYear(int.Parse(mEs.Groups[3].Value))); + + return null; + } + + private static DateTime? BuildDate(int day, int month, int year) + { + if (month < 1 || month > 12 || day < 1) return null; + if (day > DateTime.DaysInMonth(year, month)) return null; + return new DateTime(year, month, day); + } + + private static int ExpandYear(int y) => + y < 100 ? (y > 30 ? 1900 + y : 2000 + y) : y; + + public static int ComputeAge(DateTime birth) + { + var today = DateTime.Today; + var age = today.Year - birth.Year; + if (birth.Date > today.AddYears(-age)) age--; + return age; + } +} diff --git a/src/Nalu.Api/PostProcessors/RemoveTitles.cs b/src/Nalu.Api/PostProcessors/RemoveTitles.cs new file mode 100644 index 0000000..032f09f --- /dev/null +++ b/src/Nalu.Api/PostProcessors/RemoveTitles.cs @@ -0,0 +1,28 @@ +using Nalu.Web.PostProcessors; + +namespace Nalu.Web.PostProcessors; + +public class RemoveTitles : IPostProcessor +{ + public string Name => "remove_titles"; + + private static readonly HashSet Titles = new(StringComparer.OrdinalIgnoreCase) + { + "Dr.", "Dr", "Dra.", "Dra", "Sr.", "Sr", "Sra.", "Sra", + "Prof.", "Prof", "Profa.", "Profa", "Eng.", "Eng", + "Doutor", "Doutora", "Senhor", "Senhora", "Me.", "Me", "Msc" + }; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Ok(value); + + var words = value + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Where(w => !Titles.Contains(w)) + .ToArray(); + + var result = string.Join(' ', words); + return ProcessorResult.Ok(string.IsNullOrWhiteSpace(result) ? value : result); + } +} diff --git a/src/Nalu.Api/PostProcessors/SelectCancelSuggestion.cs b/src/Nalu.Api/PostProcessors/SelectCancelSuggestion.cs new file mode 100644 index 0000000..5d103c1 --- /dev/null +++ b/src/Nalu.Api/PostProcessors/SelectCancelSuggestion.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Nodes; + +namespace Nalu.Web.PostProcessors; + +/// Reads the cancel-intent JSON and sets SuggestionKeyOverride based on cancel_type + is_threat. +public class SelectCancelSuggestion : IPostProcessor +{ + public string Name => "select_cancel_suggestion"; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Ok(value); + + try + { + var obj = JsonNode.Parse(value)?.AsObject(); + if (obj is null) return ProcessorResult.Ok(value); + + var cancelType = obj["cancel_type"]?.GetValue() ?? "none"; + var isThreat = obj["is_threat"]?.GetValue() ?? false; + + var key = cancelType switch + { + "service" => isThreat ? "when_threat" : "when_cancel_service", + "operation" => "when_cancel_operation", + _ => "when_not_cancel" + }; + + return ProcessorResult.WithOverride(value, key); + } + catch + { + return ProcessorResult.Ok(value); + } + } +} diff --git a/src/Nalu.Api/PostProcessors/SelectHandoffSuggestion.cs b/src/Nalu.Api/PostProcessors/SelectHandoffSuggestion.cs new file mode 100644 index 0000000..0ee354c --- /dev/null +++ b/src/Nalu.Api/PostProcessors/SelectHandoffSuggestion.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Nodes; + +namespace Nalu.Web.PostProcessors; + +/// Reads the handoff JSON and sets SuggestionKeyOverride based on wants_human + urgency. +public class SelectHandoffSuggestion : IPostProcessor +{ + public string Name => "select_handoff_suggestion"; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Ok(value); + + try + { + var obj = JsonNode.Parse(value)?.AsObject(); + if (obj is null) return ProcessorResult.Ok(value); + + var wantsHuman = obj["wants_human"]?.GetValue() ?? false; + if (!wantsHuman) + return ProcessorResult.WithOverride(value, "when_not_handoff"); + + var urgency = obj["urgency"]?.GetValue() ?? "medium"; + var key = urgency switch + { + "high" => "when_handoff_high", + "low" => "when_handoff_low", + _ => "when_handoff_medium" + }; + + return ProcessorResult.WithOverride(value, key); + } + catch + { + return ProcessorResult.Ok(value); + } + } +} diff --git a/src/Nalu.Api/PostProcessors/ValidateCnpjDigit.cs b/src/Nalu.Api/PostProcessors/ValidateCnpjDigit.cs new file mode 100644 index 0000000..ca1c473 --- /dev/null +++ b/src/Nalu.Api/PostProcessors/ValidateCnpjDigit.cs @@ -0,0 +1,49 @@ +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. +public class ValidateCnpjDigit : IPostProcessor +{ + public string Name => "validate_cnpj_digit"; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return ProcessorResult.Invalid("CNPJ não informado"); + + var digits = Regex.Replace(value, @"\D", ""); + + if (digits.Length != 14) + return ProcessorResult.Invalid($"CNPJ deve ter 14 dígitos (encontrado: {digits.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)"); + + if (!CheckDigits(digits)) + 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..]}"; + return ProcessorResult.Ok(formatted); + } + + private static bool CheckDigits(string d) + { + // First check digit (position 12) + 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 r1 = sum1 % 11; + var cd1 = r1 < 2 ? 0 : 11 - r1; + if (d[12] - '0' != cd1) return false; + + // Second check digit (position 13) + 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 r2 = sum2 % 11; + var cd2 = r2 < 2 ? 0 : 11 - r2; + return d[13] - '0' == cd2; + } +} diff --git a/src/Nalu.Api/PostProcessors/ValidateCpfDigit.cs b/src/Nalu.Api/PostProcessors/ValidateCpfDigit.cs new file mode 100644 index 0000000..f25167f --- /dev/null +++ b/src/Nalu.Api/PostProcessors/ValidateCpfDigit.cs @@ -0,0 +1,47 @@ +using System.Text.RegularExpressions; + +namespace Nalu.Web.PostProcessors; + +public class ValidateCpfDigit : IPostProcessor +{ + public string Name => "validate_cpf_digit"; + + private static readonly string[] AllSameDigit = + Enumerable.Range(0, 10).Select(i => new string((char)('0' + i), 11)).ToArray(); + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Invalid("CPF não informado"); + + var digits = Regex.Replace(value, @"\D", ""); + + if (digits.Length != 11) + return ProcessorResult.Invalid("CPF deve ter 11 dígitos"); + + if (Array.IndexOf(AllSameDigit, digits) >= 0) + return ProcessorResult.Invalid("CPF inválido (dígitos repetidos)"); + + if (!ValidateMod11(digits)) + return ProcessorResult.Invalid("CPF inválido (dígitos verificadores incorretos)"); + + var formatted = $"{digits[..3]}.{digits[3..6]}.{digits[6..9]}-{digits[9..]}"; + return ProcessorResult.Ok(formatted); + } + + private static bool ValidateMod11(string digits) + { + // First check digit + int sum = 0; + for (int i = 0; i < 9; i++) sum += (digits[i] - '0') * (10 - i); + int rem = sum % 11; + int d1 = rem < 2 ? 0 : 11 - rem; + if (d1 != digits[9] - '0') return false; + + // Second check digit + sum = 0; + for (int i = 0; i < 10; i++) sum += (digits[i] - '0') * (11 - i); + rem = sum % 11; + int d2 = rem < 2 ? 0 : 11 - rem; + return d2 == digits[10] - '0'; + } +} diff --git a/src/Nalu.Api/Program.cs b/src/Nalu.Api/Program.cs new file mode 100644 index 0000000..56fa502 --- /dev/null +++ b/src/Nalu.Api/Program.cs @@ -0,0 +1,266 @@ +using AspNet.Security.OAuth.GitHub; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Google; +using Microsoft.AspNetCore.Authentication.MicrosoftAccount; +using MongoDB.Driver; +using Nalu.Web.Data; +using Nalu.Web.Data.Repositories; +using Nalu.Web.Endpoints; +using Nalu.Web.Enrichers; +using Nalu.Web.Infrastructure; +using Nalu.Web.Mcp; +using Nalu.Web.PostProcessors; +using Nalu.Web.Services; +using Nalu.Web.Services.LlmRouter; +using Scalar.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +// ── Razor Pages (site) ─────────────────────────────────────────────────────── +builder.Services.AddRazorPages(); + +// ── OpenAPI / Scalar ────────────────────────────────────────────────────────── +builder.Services.AddOpenApi("v1", options => +{ + options.AddDocumentTransformer((doc, _, _) => + { + doc.Info.Title = "NALU AI API"; + doc.Info.Version = "v1"; + doc.Info.Description = "Natural Language Understanding — extrai intenções reais de diálogos agente/usuário."; + return Task.CompletedTask; + }); +}); + +// ── MongoDB ─────────────────────────────────────────────────────────────────── +builder.Services.AddSingleton(sp => +{ + var connStr = builder.Configuration.GetConnectionString("MongoDB") ?? string.Empty; + if (string.IsNullOrWhiteSpace(connStr)) return null!; + + var settings = MongoClientSettings.FromConnectionString( + connStr + (connStr.Contains('?') ? "&" : "?") + + "maxPoolSize=200&minPoolSize=10&maxIdleTimeMS=30000"); + + return new MongoClient(settings); +}); + +builder.Services.AddSingleton(); + +// ── Repositories ────────────────────────────────────────────────────────────── +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// ── Authentication ─────────────────────────────────────────────────────────── +// Site = Cookie (Razor Pages). API = Bearer API key (/v1/*). DO NOT mix. +builder.Services.AddAuthentication(options => +{ + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; +}) +.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => +{ + options.LoginPath = "/login"; + options.LogoutPath = "/auth/logout"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.ExpireTimeSpan = TimeSpan.FromDays(30); + options.SlidingExpiration = true; +}) +// Temporary cookie for external OAuth handshake +.AddCookie("ExternalCookie", options => +{ + options.Cookie.Name = "nalu.external"; + options.ExpireTimeSpan = TimeSpan.FromMinutes(10); +}) +// API key scheme — used explicitly by /v1/* endpoints +.AddScheme(ApiKeyAuthScheme.Name, null) +.AddGoogle(GoogleDefaults.AuthenticationScheme, options => +{ + options.ClientId = builder.Configuration["OAuth:Google:ClientId"] ?? string.Empty; + options.ClientSecret = builder.Configuration["OAuth:Google:ClientSecret"] ?? string.Empty; + options.CallbackPath = "/signin-google"; + options.SignInScheme = "ExternalCookie"; + options.SaveTokens = false; +}) +.AddMicrosoftAccount(MicrosoftAccountDefaults.AuthenticationScheme, options => +{ + options.ClientId = builder.Configuration["OAuth:Microsoft:ClientId"] ?? string.Empty; + options.ClientSecret = builder.Configuration["OAuth:Microsoft:ClientSecret"] ?? string.Empty; + options.CallbackPath = "/signin-microsoft"; + options.SignInScheme = "ExternalCookie"; + options.SaveTokens = false; +}) +.AddGitHub(GitHubAuthenticationDefaults.AuthenticationScheme, options => +{ + options.ClientId = builder.Configuration["OAuth:GitHub:ClientId"] ?? string.Empty; + options.ClientSecret = builder.Configuration["OAuth:GitHub:ClientSecret"] ?? string.Empty; + options.CallbackPath = "/signin-github"; + options.SignInScheme = "ExternalCookie"; + options.Scope.Add("user:email"); + options.SaveTokens = false; +}); + +builder.Services.AddAuthorization(options => +{ + // API endpoints — must present a valid Bearer API key + options.AddPolicy("ApiKey", policy => + policy.AddAuthenticationSchemes(ApiKeyAuthScheme.Name) + .RequireAuthenticatedUser()); +}); + +// ── Session (required for OAuth state) ─────────────────────────────────────── +builder.Services.AddDistributedMemoryCache(); +builder.Services.AddSession(options => +{ + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + options.IdleTimeout = TimeSpan.FromMinutes(10); +}); + +// ── Caching ────────────────────────────────────────────────────────────────── +builder.Services.AddMemoryCache(); + +// ── HTTP clients ───────────────────────────────────────────────────────────── +builder.Services.AddHttpClient(client => +{ + var baseUrl = builder.Configuration["Groq:BaseUrl"] ?? "https://api.groq.com/openai/v1"; + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + '/'); + client.DefaultRequestHeaders.Add( + "Authorization", + $"Bearer {builder.Configuration["Groq:ApiKey"]}"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +// ── LLM Router (Groq → OpenRouter → Google AI) ─────────────────────────────── +builder.Services.AddHttpClient(client => +{ + var baseUrl = builder.Configuration["Groq:BaseUrl"] ?? "https://api.groq.com/openai/v1"; + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + '/'); + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {builder.Configuration["Groq:ApiKey"]}"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +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("X-Title", "NALU AI"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +builder.Services.AddHttpClient(client => +{ + var baseUrl = builder.Configuration["GoogleAi:BaseUrl"] ?? "https://generativelanguage.googleapis.com/v1beta/openai/"; + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + '/'); + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {builder.Configuration["GoogleAi:ApiKey"]}"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +builder.Services.AddSingleton(sp => +{ + var providers = new List + { + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() + }; + return new LlmRouter(providers, sp.GetRequiredService>()); +}); + +// ViaCEP enricher — no fixed base address (calls multiple URLs) +builder.Services.AddHttpClient(client => +{ + client.Timeout = TimeSpan.FromSeconds(10); + client.DefaultRequestHeaders.Add("User-Agent", "nalu-ai/1.0"); +}); +builder.Services.AddTransient(sp => sp.GetRequiredService()); + +// ── Post-processors ─────────────────────────────────────────────────────────── +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// ── Domain services ────────────────────────────────────────────────────────── +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +// EnrichmentService is Scoped because ViaCepEnricher is Transient (HttpClient lifecycle) +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// ── MCP server ──────────────────────────────────────────────────────────────── +builder.Services.AddSingleton(); + +// ── App ─────────────────────────────────────────────────────────────────────── +var app = builder.Build(); + +// Initialize MongoDB indexes on startup +var mongo = app.Services.GetRequiredService(); +await mongo.InitializeAsync(); + +app.UseSession(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapOpenApi(); +app.MapScalarApiReference(options => +{ + options.Title = "NALU AI"; + options.DefaultHttpClient = new(ScalarTarget.Http, ScalarClient.HttpClient); +}); + +app.MapRazorPages(); +app.MapExtractEndpoints(); +app.MapValidatorsEndpoints(); + +// ── Auth endpoints ──────────────────────────────────────────────────────────── +app.MapGet("/auth/logout", async (HttpContext ctx) => +{ + await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Results.Redirect("/"); +}).AllowAnonymous(); + +// ── Health check ────────────────────────────────────────────────────────────── +app.MapGet("/health", () => Results.Ok(new { status = "ok", ts = DateTime.UtcNow })) + .WithTags("Health") + .WithSummary("Health check") + .AllowAnonymous(); + +// ── MCP endpoint ────────────────────────────────────────────────────────────── +app.MapPost("/mcp", async (HttpContext ctx, McpServer mcp, CancellationToken ct) => + await mcp.HandleAsync(ctx, ct)) + .RequireAuthorization("ApiKey") + .WithName("McpEndpoint") + .WithSummary("MCP Server (JSON-RPC 2.0 / Streamable HTTP)") + .WithTags("MCP") + .WithOpenApi(); + +app.Run(); + +// Exposed for WebApplicationFactory in integration tests +public partial class Program { } diff --git a/src/Nalu.Api/Properties/launchSettings.json b/src/Nalu.Api/Properties/launchSettings.json new file mode 100644 index 0000000..66a0048 --- /dev/null +++ b/src/Nalu.Api/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar/v1", + "applicationUrl": "http://localhost:5282", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar/v1", + "applicationUrl": "https://localhost:7282;http://localhost:5282", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Nalu.Api/Services/AuthService.cs b/src/Nalu.Api/Services/AuthService.cs new file mode 100644 index 0000000..651250e --- /dev/null +++ b/src/Nalu.Api/Services/AuthService.cs @@ -0,0 +1,31 @@ +using Nalu.Web.Data.Models; +using Nalu.Web.Data.Repositories; +using Nalu.Web.Infrastructure; + +namespace Nalu.Web.Services; + +/// +/// Validates API keys. Checks MongoDB first, falls back to config for test/bootstrap keys. +/// +public class AuthService(ApiKeyRepository apiKeyRepo, IConfiguration config) +{ + private readonly List _configKeys = + config.GetSection("ApiKeys").Get>() ?? []; + + public async Task<(string Plan, string Owner)?> ValidateAsync(string? key, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(key)) return null; + + var dbKey = await apiKeyRepo.FindAsync(key, ct); + if (dbKey is not null) return (dbKey.Plan, dbKey.Owner); + + var cfgKey = _configKeys.FirstOrDefault(k => k.Key == key); + return cfgKey is not null ? (cfgKey.Plan, cfgKey.Owner) : null; + } + + public string? GetPlan(string key) + { + var cfgKey = _configKeys.FirstOrDefault(k => k.Key == key); + return cfgKey?.Plan; + } +} diff --git a/src/Nalu.Api/Services/CacheService.cs b/src/Nalu.Api/Services/CacheService.cs new file mode 100644 index 0000000..245cedb --- /dev/null +++ b/src/Nalu.Api/Services/CacheService.cs @@ -0,0 +1,38 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Caching.Memory; +using Nalu.Web.Models; + +namespace Nalu.Web.Services; + +public class CacheService +{ + private readonly IMemoryCache _cache; + private readonly IConfiguration _config; + + public CacheService(IMemoryCache cache, IConfiguration config) + { + _cache = cache; + _config = config; + } + + public bool TryGet(string validatorId, ExtractionRequest request, out ExtractionResponse? response) + { + var key = ComputeKey(validatorId, request); + return _cache.TryGetValue(key, out response); + } + + public void Set(string validatorId, ExtractionRequest request, ExtractionResponse response) + { + var ttl = _config.GetValue("Cache:DefaultTtlMinutes", 60); + var key = ComputeKey(validatorId, request); + _cache.Set(key, response, TimeSpan.FromMinutes(ttl)); + } + + private static string ComputeKey(string validatorId, ExtractionRequest request) + { + var raw = $"{validatorId}|{request.AgentInput}|{request.UserInput}|{request.Language}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); + return Convert.ToHexString(hash); + } +} diff --git a/src/Nalu.Api/Services/CreditCosts.cs b/src/Nalu.Api/Services/CreditCosts.cs new file mode 100644 index 0000000..a7328d5 --- /dev/null +++ b/src/Nalu.Api/Services/CreditCosts.cs @@ -0,0 +1,55 @@ +namespace Nalu.Web.Services; + +/// +/// Credit cost per validator. 1 = deterministic, 2 = light LLM, 5 = heavy LLM (70B context-aware). +/// +public static class CreditCosts +{ + private static readonly Dictionary _costs = new(StringComparer.OrdinalIgnoreCase) + { + // 1 credit — deterministic + ["validate_cpf"] = 1, + ["validate_cep"] = 1, + ["validate_cnpj"] = 1, + ["validate_email"] = 1, + ["validate_phone_br"] = 1, + ["validate_plate_br"] = 1, + ["validate_postal_code"]= 1, + + // 2 credits — light LLM + ["validate_full_name"] = 2, + ["validate_yes_no"] = 2, + ["validate_birthdate"] = 2, + ["validate_handoff"] = 2, + ["validate_cancel_intent"] = 2, + ["validate_company_name"] = 2, + + // 5 credits — heavy LLM (validate_reply) + ["validate_reply"] = 5, + }; + + public static int Get(string validatorId) => + _costs.TryGetValue(validatorId, out var cost) ? cost : 1; + + // Endpoint aliases → validator IDs + private static readonly Dictionary _aliases = new(StringComparer.OrdinalIgnoreCase) + { + ["name"] = "validate_full_name", + ["cpf"] = "validate_cpf", + ["cep"] = "validate_cep", + ["phone"] = "validate_phone_br", + ["email"] = "validate_email", + ["postal-code"] = "validate_postal_code", + ["yes-no"] = "validate_yes_no", + ["birthdate"] = "validate_birthdate", + ["handoff"] = "validate_handoff", + ["cancel-intent"]= "validate_cancel_intent", + ["cnpj"] = "validate_cnpj", + ["plate-br"] = "validate_plate_br", + ["company-name"] = "validate_company_name", + ["reply"] = "validate_reply", + }; + + public static int GetByEndpoint(string endpoint) => + _aliases.TryGetValue(endpoint, out var id) ? Get(id) : 1; +} diff --git a/src/Nalu.Api/Services/CreditService.cs b/src/Nalu.Api/Services/CreditService.cs new file mode 100644 index 0000000..7500896 --- /dev/null +++ b/src/Nalu.Api/Services/CreditService.cs @@ -0,0 +1,126 @@ +using System.Security.Claims; +using System.Text.Json; +using MongoDB.Driver; +using Nalu.Web.Data; +using Nalu.Web.Data.Models; + +namespace Nalu.Web.Services; + +public record CreditConsumeResult +{ + public bool Success { get; init; } + public int CreditsConsumed { get; init; } + public int CreditsUsed { get; init; } + public int CreditsLimit { get; init; } + public DateTime ResetAt { get; init; } + public object? ErrorPayload { get; init; } + + public int CreditsRemaining => Math.Max(0, CreditsLimit - CreditsUsed); +} + +public class CreditService(MongoDbContext db, IConfiguration config) +{ + public async Task TryConsumeAsync( + ClaimsPrincipal user, string validatorId, CancellationToken ct = default) + { + var apiKey = user.FindFirst("api_key")?.Value ?? ""; + var plan = user.FindFirst("plan")?.Value ?? "free"; + var cost = CreditCosts.Get(validatorId); + + var limit = GetPlanLimit(plan); + var now = DateTime.UtcNow; + var yearMonth = now.ToString("yyyy-MM"); + var resetAt = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc) + .AddMonths(1); + + if (!db.IsConnected) + { + // No MongoDB — allow all requests (dev/test mode) + return new CreditConsumeResult + { + Success = true, CreditsConsumed = cost, CreditsUsed = cost, + CreditsLimit = limit, ResetAt = resetAt + }; + } + + // Read current usage + var current = await db.UsageMonthly + .Find(u => u.ApiKey == apiKey && u.YearMonth == yearMonth) + .FirstOrDefaultAsync(ct); + + var currentTotal = current?.TotalCreditsUsed ?? 0; + + if (limit > 0 && currentTotal + cost > limit) + { + return new CreditConsumeResult + { + Success = false, + CreditsConsumed = 0, + CreditsUsed = currentTotal, + CreditsLimit = limit, + ResetAt = resetAt, + ErrorPayload = Build429Body(plan, currentTotal, limit, resetAt) + }; + } + + // Atomic increment + var filter = Builders.Filter.And( + Builders.Filter.Eq(u => u.ApiKey, apiKey), + Builders.Filter.Eq(u => u.YearMonth, yearMonth)); + + var update = Builders.Update + .Inc(u => u.TotalCreditsUsed, cost) + .Inc(u => u.TotalRequests, 1) + .Inc($"credits_by_validator.{validatorId}", cost) + .Inc($"requests_by_validator.{validatorId}", 1) + .Set(u => u.UpdatedAt, now) + .SetOnInsert(u => u.Plan, plan) + .SetOnInsert(u => u.ApiKey, apiKey) + .SetOnInsert(u => u.YearMonth, yearMonth); + + var opts = new FindOneAndUpdateOptions + { + IsUpsert = true, + ReturnDocument = ReturnDocument.After + }; + + var after = await db.UsageMonthly.FindOneAndUpdateAsync(filter, update, opts, ct); + + return new CreditConsumeResult + { + Success = true, + CreditsConsumed = cost, + CreditsUsed = after.TotalCreditsUsed, + CreditsLimit = limit, + ResetAt = resetAt + }; + } + + public void ApplyHeaders(HttpContext ctx, CreditConsumeResult result) + { + ctx.Response.Headers["X-Credits-Used"] = result.CreditsConsumed.ToString(); + ctx.Response.Headers["X-Credits-Remaining"] = result.CreditsRemaining.ToString(); + ctx.Response.Headers["X-Credits-Limit"] = result.CreditsLimit.ToString(); + ctx.Response.Headers["X-Credits-Reset"] = result.ResetAt.ToString("O"); + } + + private int GetPlanLimit(string plan) + { + var v = config.GetValue($"Plans:{plan}:credits_per_month"); + return v ?? 0; // 0 = unlimited + } + + private static object Build429Body(string plan, int used, int limit, DateTime resetAt) => new + { + error = "credits_exhausted", + message = $"Seus créditos do mês acabaram. Seu plano ({Capitalize(plan)}) permite {limit:N0} créditos/mês.", + credits_used = used, + credits_limit = limit, + reset_at = resetAt.ToString("O"), + upgrade_url = "https://naluai.com/precos", + hint = "Upgrade para Starter por apenas R$ 0,0019 por validação. Menos que uma gota de café." + }; + + private static string Capitalize(string s) => + s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..]; +} diff --git a/src/Nalu.Api/Services/DeterministicLayer.cs b/src/Nalu.Api/Services/DeterministicLayer.cs new file mode 100644 index 0000000..7d4cdc6 --- /dev/null +++ b/src/Nalu.Api/Services/DeterministicLayer.cs @@ -0,0 +1,125 @@ +using System.Text.RegularExpressions; +using Nalu.Web.Models; + +namespace Nalu.Web.Services; + +public enum DeterministicOutcome +{ + Unresolved, + Rejected, + Accepted, + ConstraintFailed +} + +public record DeterministicResult +{ + public DeterministicOutcome Outcome { get; init; } + public string? ExtractedValue { get; init; } + public string? Reasoning { get; init; } +} + +public class DeterministicLayer +{ + public DeterministicResult Evaluate(ValidatorDefinition validator, string userInput, string language = "pt-BR") + { + var normalized = userInput.Trim().ToLowerInvariant().TrimEnd('.', '!', '?', ',', ';'); + + // Use localized stop words when available, fall back to flat set + var stopWords = validator.LocalizedStopWords.TryGetValue(language, out var localized) + ? localized + : validator.StopWords; + + if (stopWords.Contains(normalized)) + { + return new DeterministicResult + { + Outcome = DeterministicOutcome.Rejected, + Reasoning = "Usuário respondeu com saudação ou palavra de parada" + }; + } + + // Reject patterns + foreach (var pattern in validator.RejectPatterns) + { + try + { + if (Regex.IsMatch(normalized, pattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100))) + { + return new DeterministicResult + { + Outcome = DeterministicOutcome.Rejected, + Reasoning = "Resposta corresponde a padrão de rejeição" + }; + } + } + catch (RegexMatchTimeoutException) { /* skip on timeout */ } + } + + // Accept patterns — capture group 1 is the extracted value + foreach (var pattern in validator.AcceptPatterns) + { + try + { + var m = Regex.Match(normalized, pattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100)); + if (!m.Success) continue; + + var extracted = m.Groups.Count > 1 && m.Groups[1].Success + ? m.Groups[1].Value.Trim() + : userInput.Trim(); + + var violation = CheckConstraints(validator.Constraints, extracted); + if (violation is not null) + { + return new DeterministicResult + { + Outcome = DeterministicOutcome.ConstraintFailed, + ExtractedValue = extracted, + Reasoning = violation + }; + } + + return new DeterministicResult + { + Outcome = DeterministicOutcome.Accepted, + ExtractedValue = extracted, + Reasoning = "Padrão de aceitação encontrado" + }; + } + catch (RegexMatchTimeoutException) { /* skip on timeout */ } + } + + return new DeterministicResult { Outcome = DeterministicOutcome.Unresolved }; + } + + private static string? CheckConstraints(Dictionary constraints, string value) + { + if (constraints.TryGetValue("min_length", out var minStr) && int.TryParse(minStr, out var min)) + { + if (value.Length < min) + return $"Valor muito curto (mínimo {min} caracteres)"; + } + + if (constraints.TryGetValue("max_length", out var maxStr) && int.TryParse(maxStr, out var max)) + { + if (value.Length > max) + return $"Valor muito longo (máximo {max} caracteres)"; + } + + if (constraints.TryGetValue("max_digits", out var maxDigStr) && int.TryParse(maxDigStr, out var maxDig)) + { + var digitCount = value.Count(char.IsDigit); + if (digitCount > maxDig) + return $"Número de dígitos excede o máximo permitido ({maxDig})"; + } + + if (constraints.TryGetValue("must_have_alpha", out var alphaStr) + && bool.TryParse(alphaStr, out var mustHaveAlpha) + && mustHaveAlpha) + { + if (!value.Any(char.IsLetter)) + return "Valor deve conter letras"; + } + + return null; + } +} diff --git a/src/Nalu.Api/Services/EnrichmentService.cs b/src/Nalu.Api/Services/EnrichmentService.cs new file mode 100644 index 0000000..ce2a877 --- /dev/null +++ b/src/Nalu.Api/Services/EnrichmentService.cs @@ -0,0 +1,32 @@ +using Nalu.Web.Enrichers; +using Nalu.Web.Models; + +namespace Nalu.Web.Services; + +public class EnrichmentService +{ + private readonly Dictionary _enrichers; + + public EnrichmentService(IEnumerable enrichers) + { + _enrichers = enrichers.ToDictionary(e => e.Name, StringComparer.OrdinalIgnoreCase); + } + + public async Task EnrichAsync(ValidatorDefinition validator, string? value, CancellationToken ct) + { + if (value is null || validator.Enrichers.Count == 0) + return EnrichmentResult.Ok(value); + + foreach (var name in validator.Enrichers) + { + if (value is null) break; + if (!_enrichers.TryGetValue(name, out var enricher)) continue; + + var result = await enricher.EnrichAsync(value, ct); + if (result.WasInvalidated) return EnrichmentResult.NotFound(); + value = result.Value; + } + + return EnrichmentResult.Ok(value); + } +} diff --git a/src/Nalu.Api/Services/ExtractionPipeline.cs b/src/Nalu.Api/Services/ExtractionPipeline.cs new file mode 100644 index 0000000..3a2ffa5 --- /dev/null +++ b/src/Nalu.Api/Services/ExtractionPipeline.cs @@ -0,0 +1,152 @@ +using Nalu.Web.Models; + +namespace Nalu.Web.Services; + +public class ExtractionPipeline +{ + private readonly ValidatorLoader _loader; + private readonly DeterministicLayer _deterministic; + private readonly LlmExtractionService _llm; + private readonly PostProcessorRegistry _postProcessors; + private readonly EnrichmentService _enrichment; + private readonly SuggestionBuilder _suggestions; + private readonly CacheService _cache; + + public ExtractionPipeline( + ValidatorLoader loader, + DeterministicLayer deterministic, + LlmExtractionService llm, + PostProcessorRegistry postProcessors, + EnrichmentService enrichment, + SuggestionBuilder suggestions, + CacheService cache) + { + _loader = loader; + _deterministic = deterministic; + _llm = llm; + _postProcessors = postProcessors; + _enrichment = enrichment; + _suggestions = suggestions; + _cache = cache; + } + + public async Task ExecuteAsync( + string validatorId, + ExtractionRequest request, + CancellationToken ct = default) + { + if (_cache.TryGet(validatorId, request, out var cached) && cached is not null) + return cached; + + var validator = _loader.Load(validatorId); + var det = _deterministic.Evaluate(validator, request.UserInput, request.Language); + + ExtractionResponse response = det.Outcome switch + { + DeterministicOutcome.Accepted => + await BuildFromValueAsync(validator, request, det.ExtractedValue, + highConfidence: true, ct), + + DeterministicOutcome.Rejected => + BuildFailed(validator, request, wasInvalidated: false), + + DeterministicOutcome.ConstraintFailed => + BuildFailed(validator, request, wasInvalidated: true), + + _ => await BuildFromLlmAsync(validator, request, ct) + }; + + _cache.Set(validatorId, request, response); + return response; + } + + private async Task BuildFromValueAsync( + ValidatorDefinition validator, + ExtractionRequest request, + string? rawValue, + bool highConfidence, + CancellationToken ct) + { + var procOutput = _postProcessors.Apply(validator.PostProcessors, rawValue); + + if (!procOutput.IsValid) + { + var sug = _suggestions.Build(validator, request, null, + obtained: false, certain: false, wasInvalidated: true); + return new ExtractionResponse + { + Obtained = false, ExtractedValue = null, Confidence = "low", + Certain = false, SuggestionToAgent = sug + }; + } + + var enrichResult = await _enrichment.EnrichAsync(validator, procOutput.Value, ct); + + if (enrichResult.WasInvalidated) + { + var sug = _suggestions.Build(validator, request, null, + obtained: false, certain: false, wasInvalidated: true); + return new ExtractionResponse + { + Obtained = false, ExtractedValue = null, Confidence = "low", + Certain = false, SuggestionToAgent = sug + }; + } + + var value = enrichResult.Value; + var obtained = value is not null; + var certain = obtained && highConfidence && !procOutput.WasCorrected; + + var suggestion = _suggestions.Build(validator, request, value, + obtained, certain, + wasInvalidated: false, + wasCorrected: procOutput.WasCorrected, + originalValue: procOutput.OriginalValue, + suggestionKeyOverride: procOutput.SuggestionKeyOverride); + + var confidence = obtained + ? (certain ? (highConfidence ? "high" : "medium") : "medium") + : "low"; + + var valueFormat = value is null ? null + : value.TrimStart().StartsWith('{') ? "object" + : "scalar"; + + return new ExtractionResponse + { + Obtained = obtained, + ExtractedValue = value, + Confidence = confidence, + Certain = certain, + SuggestionToAgent = suggestion, + ValueFormat = valueFormat + }; + } + + private ExtractionResponse BuildFailed( + ValidatorDefinition validator, + ExtractionRequest request, + bool wasInvalidated) + { + var suggestion = _suggestions.Build(validator, request, null, + obtained: false, certain: false, wasInvalidated: wasInvalidated); + + return new ExtractionResponse + { + Obtained = false, ExtractedValue = null, Confidence = "low", + Certain = false, SuggestionToAgent = suggestion + }; + } + + private async Task BuildFromLlmAsync( + ValidatorDefinition validator, + ExtractionRequest request, + CancellationToken ct) + { + var llm = await _llm.ExtractAsync(validator, request, ct); + + return await BuildFromValueAsync( + validator, request, llm.ExtractedValue, + highConfidence: llm.Certain, ct); + } +} diff --git a/src/Nalu.Api/Services/LlmExtractionService.cs b/src/Nalu.Api/Services/LlmExtractionService.cs new file mode 100644 index 0000000..a9ac95a --- /dev/null +++ b/src/Nalu.Api/Services/LlmExtractionService.cs @@ -0,0 +1,156 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Nalu.Web.Infrastructure; +using Nalu.Web.Models; + +namespace Nalu.Web.Services; + +public record LlmExtractionResult +{ + public string? ExtractedValue { get; init; } + public bool Certain { get; init; } + public string? Reasoning { get; init; } + public required string Engine { get; init; } +} + +public class LlmExtractionService +{ + private readonly GroqClient _groq; + // private readonly OpenRouterClient _openRouter; // disabled — Prompt 2 + + public LlmExtractionService(GroqClient groq) + { + _groq = groq; + // _openRouter = openRouter; + } + + public async Task ExtractAsync( + ValidatorDefinition validator, + ExtractionRequest request, + CancellationToken ct = default) + { + var systemPrompt = BuildSystemPrompt(validator); + var userMessage = BuildUserMessage(request); + + var result = await _groq.ChatAsync(systemPrompt, userMessage, ct); + + // if (result.ShouldFallback) + // { + // result = await _openRouter.ChatAsync(systemPrompt, userMessage, ct); + // engine = "llm_openrouter"; + // } + + return ParseLlmResult(result, "llm_groq"); + } + + private static string BuildSystemPrompt(ValidatorDefinition validator) + { + var sb = new StringBuilder(); + + // Extract instructions (everything before "Diálogo:") + var promptText = validator.Prompt; + var dialogIdx = promptText.IndexOf("Diálogo:", StringComparison.Ordinal); + + if (dialogIdx >= 0) + { + sb.AppendLine(promptText[..dialogIdx].Trim()); + + // Append output format instruction (after the last template placeholder) + var ctxPlaceholder = "{{agent_context}}"; + var ctxEnd = promptText.IndexOf(ctxPlaceholder, StringComparison.Ordinal); + if (ctxEnd >= 0) + { + var afterCtx = promptText[(ctxEnd + ctxPlaceholder.Length)..].Trim(); + if (!string.IsNullOrWhiteSpace(afterCtx)) + { + sb.AppendLine(); + sb.AppendLine(afterCtx); + } + } + } + else + { + sb.AppendLine(promptText.Trim()); + } + + // Few-shot examples + if (validator.FewShotExamples.Count > 0) + { + sb.AppendLine("\nExemplos:"); + foreach (var ex in validator.FewShotExamples) + { + sb.AppendLine("---"); + sb.AppendLine($"Agente: {ex.AgentInput}"); + sb.AppendLine($"Usuário: {ex.UserInput}"); + sb.AppendLine($"Output: {ex.Output}"); + } + sb.AppendLine("---"); + } + + return sb.ToString().Trim(); + } + + private static string BuildUserMessage(ExtractionRequest request) => + $""" + Diálogo: + Agente: {request.AgentInput} + Usuário: {request.UserInput} + Contexto do agente: {request.AgentContext ?? ""} + """; + + private static LlmExtractionResult ParseLlmResult(LlmCallResult result, string engine) + { + if (result.Content is null) + { + return new LlmExtractionResult + { + ExtractedValue = null, + Certain = false, + Reasoning = result.Error ?? "LLM call failed", + Engine = engine + }; + } + + try + { + var parsed = result.Content.Deserialize( + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (parsed is null) + return new LlmExtractionResult { Certain = false, Engine = engine, Reasoning = "Failed to parse LLM response" }; + + // Normalize string "null" to actual null + var extracted = parsed.ExtractedValue == "null" ? null : parsed.ExtractedValue; + + return new LlmExtractionResult + { + ExtractedValue = extracted, + Certain = parsed.Certain, + Reasoning = parsed.Reasoning, + Engine = engine + }; + } + catch + { + return new LlmExtractionResult + { + Certain = false, + Reasoning = "Could not parse LLM JSON response", + Engine = engine + }; + } + } + + private record LlmJsonResponse + { + [JsonPropertyName("extracted_value")] + public string? ExtractedValue { get; init; } + + [JsonPropertyName("certain")] + public bool Certain { get; init; } + + [JsonPropertyName("reasoning")] + public string? Reasoning { get; init; } + } +} diff --git a/src/Nalu.Api/Services/LlmRouter/GoogleAiProvider.cs b/src/Nalu.Api/Services/LlmRouter/GoogleAiProvider.cs new file mode 100644 index 0000000..4925c0d --- /dev/null +++ b/src/Nalu.Api/Services/LlmRouter/GoogleAiProvider.cs @@ -0,0 +1,74 @@ +using System.Diagnostics; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Nalu.Web.Services.LlmRouter; + +/// +/// Google AI Studio via OpenAI-compatible endpoint. +/// +public class GoogleAiProvider(HttpClient http, IConfiguration config, ILogger logger) : ILlmProvider +{ + public string Name => "google-ai"; + + public async Task CompleteAsync(LlmRequest request, CancellationToken ct) + { + var model = config["GoogleAi:Model"] ?? "gemini-2.0-flash"; + var body = new + { + model, + messages = new[] + { + new { role = "system", content = request.SystemPrompt }, + new { role = "user", content = request.UserMessage } + }, + max_tokens = request.MaxTokens, + temperature = request.Temperature, + response_format = new { type = "json_object" } + }; + + var sw = Stopwatch.StartNew(); + using var httpReq = new HttpRequestMessage(HttpMethod.Post, "chat/completions") + { + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }; + + HttpResponseMessage resp; + try + { + resp = await http.SendAsync(httpReq, ct); + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + throw new TimeoutException("Google AI request timed out"); + } + + if (resp.StatusCode == HttpStatusCode.TooManyRequests) + throw new RateLimitException(Name); + + if ((int)resp.StatusCode >= 500) + { + logger.LogWarning("Google AI server error {Status}", resp.StatusCode); + throw new RateLimitException(Name); + } + + if (!resp.IsSuccessStatusCode) + { + var err = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException($"Google AI {resp.StatusCode}: {err}"); + } + + var json = await resp.Content.ReadAsStringAsync(ct); + var doc = JsonNode.Parse(json); + var content = doc?["choices"]?[0]?["message"]?["content"]?.GetValue(); + + return new LlmResponse + { + Content = content ?? throw new InvalidOperationException("Empty content in Google AI response"), + Provider = Name, + LatencyMs = (int)sw.ElapsedMilliseconds + }; + } +} diff --git a/src/Nalu.Api/Services/LlmRouter/GroqProvider.cs b/src/Nalu.Api/Services/LlmRouter/GroqProvider.cs new file mode 100644 index 0000000..415c57e --- /dev/null +++ b/src/Nalu.Api/Services/LlmRouter/GroqProvider.cs @@ -0,0 +1,73 @@ +using System.Diagnostics; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Nalu.Web.Services.LlmRouter; + +public class GroqProvider(HttpClient http, IConfiguration config, ILogger logger) : ILlmProvider +{ + public string Name => "groq"; + + public async Task CompleteAsync(LlmRequest request, CancellationToken ct) + { + var model = config["Groq:Model"] ?? "llama-3.3-70b-versatile"; + var body = BuildBody(model, request); + + var sw = Stopwatch.StartNew(); + using var httpReq = new HttpRequestMessage(HttpMethod.Post, "chat/completions") + { + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }; + + HttpResponseMessage resp; + try + { + resp = await http.SendAsync(httpReq, ct); + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + throw new TimeoutException("Groq request timed out"); + } + + if (resp.StatusCode == HttpStatusCode.TooManyRequests) + throw new RateLimitException(Name); + + if ((int)resp.StatusCode >= 500) + { + logger.LogWarning("Groq server error {Status}", resp.StatusCode); + throw new RateLimitException(Name); // treat as transient + } + + if (!resp.IsSuccessStatusCode) + { + var err = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException($"Groq {resp.StatusCode}: {err}"); + } + + var content = await ExtractContent(resp, ct); + return new LlmResponse { Content = content, Provider = Name, LatencyMs = (int)sw.ElapsedMilliseconds }; + } + + private static object BuildBody(string model, LlmRequest r) => new + { + model, + messages = new[] + { + new { role = "system", content = r.SystemPrompt }, + new { role = "user", content = r.UserMessage } + }, + max_tokens = r.MaxTokens, + temperature = r.Temperature, + response_format = new { type = "json_object" } + }; + + private static async Task ExtractContent(HttpResponseMessage resp, CancellationToken ct) + { + var json = await resp.Content.ReadAsStringAsync(ct); + var doc = JsonNode.Parse(json); + var content = doc?["choices"]?[0]?["message"]?["content"]?.GetValue(); + return content ?? throw new InvalidOperationException("Empty content in Groq response"); + } +} diff --git a/src/Nalu.Api/Services/LlmRouter/ILlmProvider.cs b/src/Nalu.Api/Services/LlmRouter/ILlmProvider.cs new file mode 100644 index 0000000..cbdc1b3 --- /dev/null +++ b/src/Nalu.Api/Services/LlmRouter/ILlmProvider.cs @@ -0,0 +1,7 @@ +namespace Nalu.Web.Services.LlmRouter; + +public interface ILlmProvider +{ + string Name { get; } + Task CompleteAsync(LlmRequest request, CancellationToken ct); +} diff --git a/src/Nalu.Api/Services/LlmRouter/ILlmRouter.cs b/src/Nalu.Api/Services/LlmRouter/ILlmRouter.cs new file mode 100644 index 0000000..50ab508 --- /dev/null +++ b/src/Nalu.Api/Services/LlmRouter/ILlmRouter.cs @@ -0,0 +1,6 @@ +namespace Nalu.Web.Services.LlmRouter; + +public interface ILlmRouter +{ + Task CompleteAsync(LlmRequest request, CancellationToken ct); +} diff --git a/src/Nalu.Api/Services/LlmRouter/LlmModels.cs b/src/Nalu.Api/Services/LlmRouter/LlmModels.cs new file mode 100644 index 0000000..b29e27a --- /dev/null +++ b/src/Nalu.Api/Services/LlmRouter/LlmModels.cs @@ -0,0 +1,23 @@ +namespace Nalu.Web.Services.LlmRouter; + +public record LlmRequest +{ + public required string SystemPrompt { get; init; } + public required string UserMessage { get; init; } + public int MaxTokens { get; init; } = 800; + public double Temperature { get; init; } = 0.1; +} + +public record LlmResponse +{ + public required string Content { get; init; } + public required string Provider { get; init; } + public int LatencyMs { get; init; } +} + +public class RateLimitException(string provider) : Exception($"{provider} rate limited") +{ + public string Provider { get; } = provider; +} + +public class ServiceUnavailableException(string message) : Exception(message); diff --git a/src/Nalu.Api/Services/LlmRouter/LlmRouter.cs b/src/Nalu.Api/Services/LlmRouter/LlmRouter.cs new file mode 100644 index 0000000..4919d4b --- /dev/null +++ b/src/Nalu.Api/Services/LlmRouter/LlmRouter.cs @@ -0,0 +1,29 @@ +namespace Nalu.Web.Services.LlmRouter; + +public class LlmRouter(IReadOnlyList providers, ILogger logger) : ILlmRouter +{ + public async Task CompleteAsync(LlmRequest request, CancellationToken ct) + { + foreach (var provider in providers) + { + try + { + return await provider.CompleteAsync(request, ct); + } + catch (RateLimitException ex) + { + logger.LogWarning("Provider {Provider} rate limited, trying next", ex.Provider); + } + catch (TimeoutException) + { + logger.LogWarning("Provider {Provider} timed out, trying next", provider.Name); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Provider {Provider} failed, trying next", provider.Name); + } + } + + throw new ServiceUnavailableException("All LLM providers unavailable"); + } +} diff --git a/src/Nalu.Api/Services/LlmRouter/OpenRouterProvider.cs b/src/Nalu.Api/Services/LlmRouter/OpenRouterProvider.cs new file mode 100644 index 0000000..ab2b28d --- /dev/null +++ b/src/Nalu.Api/Services/LlmRouter/OpenRouterProvider.cs @@ -0,0 +1,71 @@ +using System.Diagnostics; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Nalu.Web.Services.LlmRouter; + +public class OpenRouterProvider(HttpClient http, IConfiguration config, ILogger logger) : ILlmProvider +{ + public string Name => "openrouter"; + + public async Task CompleteAsync(LlmRequest request, CancellationToken ct) + { + var model = config["OpenRouter:Model"] ?? "meta-llama/llama-3.3-70b-instruct"; + var body = new + { + model, + messages = new[] + { + new { role = "system", content = request.SystemPrompt }, + new { role = "user", content = request.UserMessage } + }, + max_tokens = request.MaxTokens, + temperature = request.Temperature, + response_format = new { type = "json_object" } + }; + + var sw = Stopwatch.StartNew(); + using var httpReq = new HttpRequestMessage(HttpMethod.Post, "chat/completions") + { + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }; + + HttpResponseMessage resp; + try + { + resp = await http.SendAsync(httpReq, ct); + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + throw new TimeoutException("OpenRouter request timed out"); + } + + if (resp.StatusCode == HttpStatusCode.TooManyRequests) + throw new RateLimitException(Name); + + if ((int)resp.StatusCode >= 500) + { + logger.LogWarning("OpenRouter server error {Status}", resp.StatusCode); + throw new RateLimitException(Name); + } + + if (!resp.IsSuccessStatusCode) + { + var err = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException($"OpenRouter {resp.StatusCode}: {err}"); + } + + var json = await resp.Content.ReadAsStringAsync(ct); + var doc = JsonNode.Parse(json); + var content = doc?["choices"]?[0]?["message"]?["content"]?.GetValue(); + + return new LlmResponse + { + Content = content ?? throw new InvalidOperationException("Empty content in OpenRouter response"), + Provider = Name, + LatencyMs = (int)sw.ElapsedMilliseconds + }; + } +} diff --git a/src/Nalu.Api/Services/PostProcessorRegistry.cs b/src/Nalu.Api/Services/PostProcessorRegistry.cs new file mode 100644 index 0000000..74e3b50 --- /dev/null +++ b/src/Nalu.Api/Services/PostProcessorRegistry.cs @@ -0,0 +1,61 @@ +using Nalu.Web.PostProcessors; + +namespace Nalu.Web.Services; + +public record PostProcessorOutput +{ + public string? Value { get; init; } + public bool IsValid { get; init; } = true; + public bool WasCorrected { get; init; } + public string? OriginalValue { get; init; } + public string? InvalidReason { get; init; } + /// Overrides suggestion template key — set by processors like CalculateAge ("when_minor"). + public string? SuggestionKeyOverride { get; init; } +} + +public class PostProcessorRegistry +{ + private readonly Dictionary _processors; + + public PostProcessorRegistry(IEnumerable processors) + { + _processors = processors.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase); + } + + public PostProcessorOutput Apply(IReadOnlyList processorNames, string? value) + { + bool wasCorrected = false; + string? originalValue = null; + string? suggestionKeyOverride = null; + + foreach (var name in processorNames) + { + if (!_processors.TryGetValue(name, out var processor)) continue; + + var result = processor.Process(value); + + if (!result.IsValid) + return new PostProcessorOutput { Value = null, IsValid = false, InvalidReason = result.InvalidReason }; + + if (result.WasCorrected) + { + wasCorrected = true; + originalValue ??= result.OriginalValue; + } + + if (result.SuggestionKeyOverride is not null) + suggestionKeyOverride = result.SuggestionKeyOverride; + + value = result.Value; + } + + return new PostProcessorOutput + { + Value = value, + IsValid = true, + WasCorrected = wasCorrected, + OriginalValue = originalValue, + SuggestionKeyOverride = suggestionKeyOverride + }; + } +} diff --git a/src/Nalu.Api/Services/RateLimitService.cs b/src/Nalu.Api/Services/RateLimitService.cs new file mode 100644 index 0000000..f7acb3a --- /dev/null +++ b/src/Nalu.Api/Services/RateLimitService.cs @@ -0,0 +1,80 @@ +using System.Collections.Concurrent; +using Nalu.Web.Data.Repositories; + +namespace Nalu.Web.Services; + +public class RateLimitService(IConfiguration config, UsageRepository usageRepo) +{ + private readonly ConcurrentDictionary _fallback = new(); + + /// + /// Returns true and increments counters if the key is within limits. + /// Uses MongoDB when connected; falls back to in-memory. + /// + public async Task TryConsumeAsync(string apiKey, string plan, CancellationToken ct = default) + { + var planSection = config.GetSection($"Plans:{plan}"); + var dailyLimit = planSection.GetValue("daily_limit"); + var monthlyLimit = planSection.GetValue("monthly_limit"); + + // No limits configured — always allow + if (!dailyLimit.HasValue && !monthlyLimit.HasValue) return true; + + var mongoResult = await usageRepo.IncrementAsync(apiKey, plan, ct); + + if (mongoResult.HasValue) + { + var (daily, monthly) = mongoResult.Value; + if (dailyLimit.HasValue && daily > dailyLimit.Value) return false; + if (monthlyLimit.HasValue && monthly > monthlyLimit.Value) return false; + return true; + } + + // Fallback: in-memory + return TryConsumeFallback(apiKey, dailyLimit, monthlyLimit); + } + + // Kept for sync callers and backwards compat during migration + public bool TryConsume(string apiKey, string plan) + { + var planSection = config.GetSection($"Plans:{plan}"); + var dailyLimit = planSection.GetValue("daily_limit"); + var monthlyLimit = planSection.GetValue("monthly_limit"); + + if (!dailyLimit.HasValue && !monthlyLimit.HasValue) return true; + + return TryConsumeFallback(apiKey, dailyLimit, monthlyLimit); + } + + private bool TryConsumeFallback(string apiKey, int? dailyLimit, int? monthlyLimit) + { + var now = DateTime.UtcNow; + var today = DateOnly.FromDateTime(now); + var month = new YearMonth(now.Year, now.Month); + + var record = _fallback.GetOrAdd(apiKey, _ => new InMemoryUsage(today, month)); + + lock (record) + { + if (record.Day != today) { record.Day = today; record.DailyCount = 0; } + if (record.Month != month) { record.Month = month; record.MonthlyCount = 0; } + + if (dailyLimit.HasValue && record.DailyCount >= dailyLimit.Value) return false; + if (monthlyLimit.HasValue && record.MonthlyCount >= monthlyLimit.Value) return false; + + record.DailyCount++; + record.MonthlyCount++; + return true; + } + } + + private sealed class InMemoryUsage(DateOnly day, YearMonth month) + { + public DateOnly Day { get; set; } = day; + public int DailyCount { get; set; } + public YearMonth Month { get; set; } = month; + public int MonthlyCount { get; set; } + } + + private record YearMonth(int Year, int Month); +} diff --git a/src/Nalu.Api/Services/ReplyService.cs b/src/Nalu.Api/Services/ReplyService.cs new file mode 100644 index 0000000..ee92bf7 --- /dev/null +++ b/src/Nalu.Api/Services/ReplyService.cs @@ -0,0 +1,160 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Nalu.Web.Models; +using Nalu.Web.Services.LlmRouter; + +namespace Nalu.Web.Services; + +public class ReplyService(ILlmRouter llmRouter, ILogger logger) +{ + private const string SystemPrompt = """ + You are a conversational reply analyzer. Given an agent's message and the user's reply, determine: + 1. The type of reply (from a fixed taxonomy) + 2. What the user actually meant (considering the context of the agent's message) + 3. Whether clarification is needed + 4. A suggested follow-up message for the agent (in the language specified) + + Reply types (pick exactly one): + - answer: User directly answered what was asked + - question: User asked a question back + - counter_proposal: User proposed an alternative to what was offered + - confirmation: User confirmed positively + - rejection: User rejected or refused + - off_topic: User talked about something unrelated + - greeting: User sent a greeting without useful content + - handoff: User wants to talk to a human + - cancel: User wants to quit or cancel + - unclear: Ambiguous, needs clarification + + CRITICAL: When the agent mentions specific numbers (prices, quantities, installments) and the user replies with a different number, carefully analyze whether the user is: + - Proposing a different QUANTITY (e.g., different number of installments) + - Proposing a different AMOUNT (e.g., different price) + - Confirming the original number + The context of the agent's message determines the meaning of the user's number. + + value_type options: "quantity", "amount", "date", "text", "boolean" — or null if no value extracted. + + Respond ONLY in JSON format, no preamble, no markdown: + { + "reply_type": "string", + "extracted_value": "string or null", + "value_type": "string or null", + "extracted_meaning": "string explaining what the user meant", + "confidence": float 0-1, + "needs_clarification": boolean, + "suggestion_to_agent": "string or null" + } + """; + + public async Task AnalyzeAsync(ReplyRequest request, CancellationToken ct = default) + { + var userMessage = BuildUserMessage(request); + + try + { + var llmResp = await llmRouter.CompleteAsync(new LlmRequest + { + SystemPrompt = SystemPrompt, + UserMessage = userMessage, + MaxTokens = 600, + Temperature = 0.1 + }, ct); + + return ParseResponse(llmResp); + } + catch (ServiceUnavailableException ex) + { + logger.LogError(ex, "All LLM providers failed for validate_reply"); + throw; + } + } + + private static string BuildUserMessage(ReplyRequest r) => + $""" + Language: {r.Language} + + Agent message: {r.AgentMessage} + User reply: {r.UserReply} + + Generate suggestion_to_agent in {r.Language}. + """; + + private static ReplyResponse ParseResponse(LlmResponse llmResp) + { + try + { + var parsed = JsonSerializer.Deserialize( + llmResp.Content, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (parsed is null) + return Failed(); + + var replyType = ParseReplyType(parsed.ReplyType); + + return new ReplyResponse + { + Obtained = replyType is not null, + ReplyType = replyType, + ExtractedValue = parsed.ExtractedValue, + ValueType = parsed.ValueType, + ExtractedMeaning = parsed.ExtractedMeaning, + Confidence = Math.Clamp(parsed.Confidence, 0.0, 1.0), + NeedsClarification = parsed.NeedsClarification, + SuggestionToAgent = parsed.SuggestionToAgent + }; + } + catch + { + return Failed(); + } + } + + private static ReplyType? ParseReplyType(string? raw) => raw?.ToLowerInvariant() switch + { + "answer" => Models.ReplyType.Answer, + "question" => Models.ReplyType.Question, + "counter_proposal" => Models.ReplyType.CounterProposal, + "confirmation" => Models.ReplyType.Confirmation, + "rejection" => Models.ReplyType.Rejection, + "off_topic" => Models.ReplyType.OffTopic, + "greeting" => Models.ReplyType.Greeting, + "handoff" => Models.ReplyType.Handoff, + "cancel" => Models.ReplyType.Cancel, + "unclear" => Models.ReplyType.Unclear, + _ => null + }; + + private static ReplyResponse Failed() => new() + { + Obtained = false, + Confidence = 0.0, + NeedsClarification = true, + ExtractedMeaning = "Could not analyze reply" + }; + + private record LlmReplyJson + { + [JsonPropertyName("reply_type")] + public string? ReplyType { get; init; } + + [JsonPropertyName("extracted_value")] + public string? ExtractedValue { get; init; } + + [JsonPropertyName("value_type")] + public string? ValueType { get; init; } + + [JsonPropertyName("extracted_meaning")] + public string? ExtractedMeaning { get; init; } + + [JsonPropertyName("confidence")] + public double Confidence { get; init; } + + [JsonPropertyName("needs_clarification")] + public bool NeedsClarification { get; init; } + + [JsonPropertyName("suggestion_to_agent")] + public string? SuggestionToAgent { get; init; } + } +} diff --git a/src/Nalu.Api/Services/SuggestionBuilder.cs b/src/Nalu.Api/Services/SuggestionBuilder.cs new file mode 100644 index 0000000..3ed5b53 --- /dev/null +++ b/src/Nalu.Api/Services/SuggestionBuilder.cs @@ -0,0 +1,119 @@ +using System.Text.Json.Nodes; +using Nalu.Web.Models; + +namespace Nalu.Web.Services; + +public class SuggestionBuilder +{ + private static readonly Dictionary GreetingMap = new(StringComparer.OrdinalIgnoreCase) + { + { "bom dia", "Bom dia!" }, + { "boa tarde", "Boa tarde!" }, + { "boa noite", "Boa noite!" }, + { "olá", "Olá!" }, + { "ola", "Olá!" }, + { "oi", "Oi!" }, + { "eae", "Oi!" }, + { "e aí", "Oi!" }, + { "fala", "Oi!" }, + { "opa", "Oi!" } + }; + + public string? Build( + ValidatorDefinition validator, + ExtractionRequest request, + string? extractedValue, + bool obtained, + bool certain, + bool wasInvalidated = false, + bool wasCorrected = false, + string? originalValue = null, + string? suggestionKeyOverride = null) + { + var templateKey = suggestionKeyOverride + ?? SelectTemplateKey(request.UserInput, obtained, certain, wasInvalidated, wasCorrected); + + var template = GetTemplate(validator, templateKey, request.Language); + if (string.IsNullOrWhiteSpace(template)) return null; + + return ExpandPlaceholders(template, extractedValue, request.UserInput, originalValue); + } + + private static string? GetTemplate(ValidatorDefinition validator, string key, string language) + { + // Try language-specific first + if (validator.LocalizedSuggestions.TryGetValue(language, out var localized) + && localized.TryGetValue(key, out var localizedText)) + return localizedText; + + // Fall back to flat suggestions + return validator.Suggestions.TryGetValue(key, out var flat) ? flat : null; + } + + private static string SelectTemplateKey( + string userInput, bool obtained, bool certain, bool wasInvalidated, bool wasCorrected) + { + if (!obtained) + { + if (wasInvalidated) return "when_invalid"; + return IsGreeting(userInput) ? "when_null_greeting" : "when_null_evasive"; + } + + if (!certain) + { + if (wasCorrected) return "when_corrected"; + return "when_uncertain"; + } + + return "when_certain"; + } + + private static string ExpandPlaceholders( + string template, string? extractedValue, string userInput, string? originalValue) + { + var greeting = DetectGreetingResponse(userInput); + + var result = template + .Replace("{{extracted_value}}", extractedValue ?? "") + .Replace("{{greeting_response}}", greeting) + .Replace("{{original}}", originalValue ?? ""); + + // JSON field expansion — for enriched values like CEP address + if (extractedValue?.TrimStart().StartsWith('{') == true) + { + try + { + var obj = JsonNode.Parse(extractedValue)?.AsObject(); + if (obj != null) + { + foreach (var prop in obj) + result = result.Replace($"{{{{{prop.Key}}}}}", prop.Value?.GetValue() ?? ""); + } + } + catch { /* non-JSON, skip */ } + } + + return result; + } + + private static bool IsGreeting(string userInput) + { + var normalized = userInput.Trim().ToLowerInvariant().TrimEnd('.', '!', '?', ','); + return GreetingMap.Keys.Any(g => + normalized == g + || normalized.StartsWith(g + " ", StringComparison.Ordinal) + || normalized.StartsWith(g + ",", StringComparison.Ordinal) + || normalized.StartsWith(g + "!", StringComparison.Ordinal)); + } + + private static string DetectGreetingResponse(string userInput) + { + var normalized = userInput.Trim().ToLowerInvariant(); + foreach (var (key, response) in GreetingMap) + { + if (normalized.StartsWith(key, StringComparison.Ordinal)) + return response; + } + return ""; + } +} diff --git a/src/Nalu.Api/Services/UserService.cs b/src/Nalu.Api/Services/UserService.cs new file mode 100644 index 0000000..a8b2d45 --- /dev/null +++ b/src/Nalu.Api/Services/UserService.cs @@ -0,0 +1,78 @@ +using System.Security.Cryptography; +using Nalu.Web.Data.Models; +using Nalu.Web.Data.Repositories; + +namespace Nalu.Web.Services; + +public class UserService(UserRepository users, ApiKeyRepository apiKeys, ILogger logger) +{ + public record LoginResult(NaluUser User, ApiKey ApiKey, bool IsNew); + + public async Task LoginOrCreateAsync( + string provider, string providerId, + string email, string? name, string? pictureUrl, + CancellationToken ct = default) + { + // Find existing user + var user = await users.FindByProviderAsync(provider, providerId, ct); + + if (user is not null) + { + // Update profile on each login + user.Name = name; + user.PictureUrl = pictureUrl; + user.LastLoginAt = DateTime.UtcNow; + await users.UpsertAsync(user, ct); + + var existingKey = (await apiKeys.GetByUserAsync(user.Id, ct)).FirstOrDefault(); + if (existingKey is not null) + return new LoginResult(user, existingKey, IsNew: false); + + // User exists but somehow has no key — create one + var key = await CreateApiKeyAsync(user, ct); + return new LoginResult(user, key, IsNew: false); + } + + // New user + user = new NaluUser + { + Email = email, + Name = name, + Provider = provider, + ProviderId = providerId, + PictureUrl = pictureUrl, + Plan = "free", + }; + await users.UpsertAsync(user, ct); + + // Re-fetch to get DB-assigned id after upsert + user = await users.FindByProviderAsync(provider, providerId, ct) ?? user; + + var newKey = await CreateApiKeyAsync(user, ct); + logger.LogInformation("New user created: {Email} via {Provider}", email, provider); + + return new LoginResult(user, newKey, IsNew: true); + } + + private async Task CreateApiKeyAsync(NaluUser user, CancellationToken ct) + { + var rawKey = $"nalu-{GenerateRandomBase62(32)}"; + var apiKey = new ApiKey + { + Key = rawKey, + Plan = user.Plan, + Owner = user.Email, + UserId = user.Id, + Label = "Default", + }; + await apiKeys.InsertAsync(apiKey, ct); + return apiKey; + } + + private static string GenerateRandomBase62(int length) + { + const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + var bytes = RandomNumberGenerator.GetBytes(length); + return new string(bytes.Select(b => chars[b % chars.Length]).ToArray()); + } +} diff --git a/src/Nalu.Api/Services/ValidatorLoader.cs b/src/Nalu.Api/Services/ValidatorLoader.cs new file mode 100644 index 0000000..5d5dfe4 --- /dev/null +++ b/src/Nalu.Api/Services/ValidatorLoader.cs @@ -0,0 +1,294 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; +using Nalu.Web.Models; + +namespace Nalu.Web.Services; + +public class ValidatorLoader : IDisposable +{ + private readonly string _validatorsPath; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _cache = new(); + private FileSystemWatcher? _watcher; + + public ValidatorLoader(ILogger logger, IWebHostEnvironment env) + { + _logger = logger; + _validatorsPath = Path.Combine(env.ContentRootPath, "Validators"); + + if (Directory.Exists(_validatorsPath)) + { + _watcher = new FileSystemWatcher(_validatorsPath, "*.md") + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName, + EnableRaisingEvents = true + }; + + _watcher.Changed += Invalidate; + _watcher.Deleted += Invalidate; + _watcher.Renamed += Invalidate; + } + } + + private void Invalidate(object _, FileSystemEventArgs e) + { + var id = Path.GetFileNameWithoutExtension(e.Name ?? ""); + if (_cache.TryRemove(id, out ValidatorDefinition? _)) + _logger.LogInformation("Invalidated validator cache for '{Id}'", id); + } + + public ValidatorDefinition Load(string validatorId) + { + return _cache.GetOrAdd(validatorId, id => + { + var path = Path.Combine(_validatorsPath, $"{id}.md"); + if (!File.Exists(path)) + throw new FileNotFoundException($"Validator '{id}' not found at '{path}'"); + + var content = File.ReadAllText(path); + _logger.LogDebug("Parsed validator '{Id}' from disk", id); + return ParseFromContent(id, content); + }); + } + + public IEnumerable LoadAll() + { + if (!Directory.Exists(_validatorsPath)) + return []; + + return Directory + .GetFiles(_validatorsPath, "*.md") + .Select(f => Load(Path.GetFileNameWithoutExtension(f))); + } + + /// Parses a validator .md string. Public for unit testing. + public static ValidatorDefinition ParseFromContent(string id, string content) + { + var def = new ValidatorDefinition { Id = id }; + + // Description: text between # title line and first ## section + var descMatch = Regex.Match(content, + @"^#[^\n]+\n(.*?)(?=^##|\z)", + RegexOptions.Multiline | RegexOptions.Singleline); + if (descMatch.Success) + def.Description = descMatch.Groups[1].Value.Trim(); + + // Split into ## sections + var sections = Regex.Split(content, @"^## ", RegexOptions.Multiline); + + foreach (var section in sections) + { + if (string.IsNullOrWhiteSpace(section)) continue; + + var nlIdx = section.IndexOf('\n'); + if (nlIdx < 0) continue; + + var sectionName = section[..nlIdx].Trim().ToLowerInvariant(); + var body = section[(nlIdx + 1)..]; + + switch (sectionName) + { + case "config": + ParseConfig(def, body); + break; + case "deterministic_rules": + ParseDeterministicRules(def, body); + break; + case "prompt": + def.Prompt = body.Trim(); + break; + case "few_shot_examples": + ParseFewShots(def, body); + break; + case "post_processors": + def.PostProcessors = ParseBulletList(body); + break; + case "enrichment": + if (!body.Contains("nenhum", StringComparison.OrdinalIgnoreCase)) + def.Enrichers = ParseBulletList(body); + break; + case "suggestions": + ParseSuggestions(def, body); + break; + } + } + + return def; + } + + private static void ParseConfig(ValidatorDefinition def, string body) + { + foreach (var line in body.Split('\n')) + { + var m = Regex.Match(line.Trim(), @"^-\s+(\w+):\s*(.+)$"); + if (!m.Success) continue; + + var key = m.Groups[1].Value; + var value = m.Groups[2].Value.Trim(); + + switch (key) + { + case "type": def.Type = value; break; + case "version": def.Version = value; break; + case "languages": + def.Languages = value.Split(',').Select(l => l.Trim()).ToList(); + break; + case "endpoint": def.Endpoint = value; break; + case "mcp_tool": def.McpTool = value; break; + case "mcp_description": def.McpDescription = value; break; + } + } + } + + private static void ParseDeterministicRules(ValidatorDefinition def, string body) + { + var subsections = Regex.Split(body, @"^### ", RegexOptions.Multiline); + + foreach (var sub in subsections) + { + if (string.IsNullOrWhiteSpace(sub)) continue; + + var nlIdx = sub.IndexOf('\n'); + if (nlIdx < 0) continue; + + var subName = sub[..nlIdx].Trim().ToLowerInvariant(); + var subBody = sub[(nlIdx + 1)..]; + + switch (subName) + { + case "stop_words": + if (subBody.Contains("#### ")) + { + // Localized stop words per language (#### pt-BR / #### en-US / ...) + var langSecs = Regex.Split(subBody, @"^#### ", RegexOptions.Multiline); + foreach (var langSec in langSecs) + { + if (string.IsNullOrWhiteSpace(langSec)) continue; + var lnl = langSec.IndexOf('\n'); + if (lnl < 0) continue; + var lang = langSec[..lnl].Trim(); + var words = langSec[(lnl + 1)..].Trim() + .Split(',') + .Select(s => s.Trim().ToLowerInvariant()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToHashSet(); + def.LocalizedStopWords[lang] = words; + foreach (var w in words) def.StopWords.Add(w); // flat fallback + } + } + else + { + def.StopWords = subBody + .Trim() + .Split(',') + .Select(s => s.Trim().ToLowerInvariant()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToHashSet(); + } + break; + + case "reject_patterns": + def.RejectPatterns = ParseBulletList(subBody); + break; + + case "accept_patterns": + def.AcceptPatterns = ParseBulletList(subBody); + break; + + case "constraints": + def.Constraints = ParseBulletList(subBody) + .Select(l => l.Split(':', 2)) + .Where(p => p.Length == 2) + .ToDictionary(p => p[0].Trim(), p => p[1].Trim()); + break; + } + } + } + + private static void ParseFewShots(ValidatorDefinition def, string body) + { + var examples = Regex.Split(body, @"^### example \d+", RegexOptions.Multiline | RegexOptions.IgnoreCase); + + foreach (var ex in examples) + { + if (string.IsNullOrWhiteSpace(ex)) continue; + + var agentInput = ExtractBulletValue(ex, "agent_input"); + var userInput = ExtractBulletValue(ex, "user_input"); + var output = ExtractBulletValue(ex, "output"); + + if (agentInput is not null && userInput is not null && output is not null) + { + def.FewShotExamples.Add(new FewShotExample + { + AgentInput = agentInput, + UserInput = userInput, + Output = output + }); + } + } + } + + private static string? ExtractBulletValue(string text, string key) + { + var m = Regex.Match(text, $@"^-\s+{Regex.Escape(key)}:\s*(.+)$", RegexOptions.Multiline); + return m.Success ? m.Groups[1].Value.Trim() : null; + } + + private static void ParseSuggestions(ValidatorDefinition def, string body) + { + var subsections = Regex.Split(body, @"^### ", RegexOptions.Multiline); + + foreach (var sub in subsections) + { + if (string.IsNullOrWhiteSpace(sub)) continue; + + var nlIdx = sub.IndexOf('\n'); + if (nlIdx < 0) continue; + + var key = sub[..nlIdx].Trim().ToLowerInvariant(); + var rawValue = sub[(nlIdx + 1)..]; + + if (string.IsNullOrEmpty(key)) continue; + + if (rawValue.Contains("#### ")) + { + // Localized suggestion per language + var langSecs = Regex.Split(rawValue, @"^#### ", RegexOptions.Multiline); + foreach (var langSec in langSecs) + { + if (string.IsNullOrWhiteSpace(langSec)) continue; + var lnl = langSec.IndexOf('\n'); + if (lnl < 0) continue; + var lang = langSec[..lnl].Trim(); + var text = langSec[(lnl + 1)..].Trim(); + if (string.IsNullOrWhiteSpace(text) || text.StartsWith('(')) continue; + + if (!def.LocalizedSuggestions.ContainsKey(lang)) + def.LocalizedSuggestions[lang] = new Dictionary(StringComparer.OrdinalIgnoreCase); + def.LocalizedSuggestions[lang][key] = text; + } + } + else + { + var value = rawValue.Trim(); + if (value.StartsWith('(')) continue; // placeholder like "(sem sugestão)" + def.Suggestions[key] = value; + } + } + } + + private static List ParseBulletList(string body) => + body.Split('\n') + .Select(l => l.Trim()) + .Where(l => l.StartsWith("- ")) + .Select(l => l[2..].Trim()) + .Where(l => !string.IsNullOrEmpty(l)) + .ToList(); + + public void Dispose() + { + _watcher?.Dispose(); + _watcher = null; + } +} diff --git a/src/Nalu.Api/Validators/validate_birthdate.md b/src/Nalu.Api/Validators/validate_birthdate.md new file mode 100644 index 0000000..ed31803 --- /dev/null +++ b/src/Nalu.Api/Validators/validate_birthdate.md @@ -0,0 +1,151 @@ +# validate_birthdate + +Extracts date of birth and calculates age from conversation. + +## config + +- type: extraction +- version: 1.0 +- languages: pt-BR, en-US, es-ES +- endpoint: /v1/extract/birthdate +- mcp_tool: nalu_extract_birthdate +- mcp_description: Extracts the user's date of birth from conversation. Supports multiple formats (DD/MM/YYYY, "March 15, 1990", "quinze de março de noventa"). Automatically calculates current age. Returns certain=false if the date is ambiguous or approximate. If has_suggestion=true and the key is when_minor, the user is under 18 — proceed according to your flow. If certain=true, accept the value and continue. + +## deterministic_rules + +### stop_words +#### pt-BR +não lembro, não sei, não tenho certeza, bom dia, boa tarde, boa noite, olá, oi + +#### en-US +i don't know, i don't remember, not sure, hello, hi, good morning, good afternoon + +#### es-ES +no recuerdo, no sé, hola, buenos días, buenas tardes + +### reject_patterns +# Removed: ^[a-zA-Z\s]+$ blocked written-out dates like "quince de marzo de noventa". +# Stop_words handle greetings. LLM handles evasive responses. + +### accept_patterns +- (\d{4}-\d{2}-\d{2}) +- ((?:0?[1-9]|[12]\d|3[01])[/.\-](?:0?[1-9]|1[0-2])[/.\-]\d{4}) +- ((?:1[3-9]|[2-3]\d)[/.\-]\d{2}[/.\-]\d{4}) + +### constraints +- min_length: 6 + +## prompt + +You are a date of birth extractor. Given the dialogue below, extract the user's date of birth. + +Rules: +1. Extract dates in any format (numeric, written, partial). +2. Normalize to ISO 8601 format: YYYY-MM-DD. +3. If the user gave only their age ("I'm 36"), estimate the birth year as current year minus age. Return certain: false and date as YYYY-01-01. +4. If pt-BR or es-ES: assume DD/MM/YYYY for ambiguous dates. If en-US: assume MM/DD/YYYY. +5. If the date is in the future or older than 130 years, return extracted_value: null. +6. If the user was evasive, return extracted_value: null. + +Dialogue: +Agent: {{agent_input}} +User: {{user_input}} + +Agent context: {{agent_context}} + +Reply ONLY with valid JSON, no markdown, no explanation: +{ + "extracted_value": "YYYY-MM-DD or null", + "certain": true/false, + "reasoning": "short explanation" +} + +## few_shot_examples + +### example 1 +- agent_input: What's your date of birth? +- user_input: 03/15/1990 +- output: {"extracted_value": "1990-03-15", "certain": true, "reasoning": "Standard US date format MM/DD/YYYY"} + +### example 2 +- agent_input: Qual sua data de nascimento? +- user_input: quinze de março de noventa +- output: {"extracted_value": "1990-03-15", "certain": true, "reasoning": "Date written in full in Portuguese"} + +### example 3 +- agent_input: When were you born? +- user_input: I'm 36 +- output: {"extracted_value": "1989-01-01", "certain": false, "reasoning": "Only age provided — birth year estimated, day/month unknown"} + +### example 4 +- agent_input: Data de nascimento? +- user_input: não lembro exatamente +- output: {"extracted_value": null, "certain": false, "reasoning": "User does not remember their birth date"} + +### example 5 +- agent_input: When were you born? +- user_input: 15/03/2030 +- output: {"extracted_value": null, "certain": false, "reasoning": "Date is in the future — invalid birth date"} + +### example 6 +- agent_input: Qual sua data de nascimento? +- user_input: 15/03/1990 +- output: {"extracted_value": "1990-03-15", "certain": true, "reasoning": "Date in DD/MM/YYYY format (pt-BR)"} + +NOTE: The LLM must return the ISO date string (YYYY-MM-DD). The parse_date and calculate_age post-processors transform it to JSON: {"date":"1990-03-15","formatted":"15/03/1990","age":35,"minor":false}. The final API response will contain the JSON string in extracted_value with value_format="object". + +## post_processors +- parse_date +- calculate_age + +## suggestions + +### when_null_evasive +#### pt-BR +Preciso da sua data de nascimento para continuar. Qual é? (Ex: 15/03/1990) + +#### en-US +I need your date of birth to continue. When were you born? (e.g. 03/15/1990) + +#### es-ES +Necesito tu fecha de nacimiento para continuar. ¿Cuál es? (Ej: 15/03/1990) + +### when_uncertain +#### pt-BR +Só para confirmar: sua data de nascimento é {{formatted}}, e você tem {{age}} anos. Está correto? + +#### en-US +Just to confirm: your date of birth is {{formatted}}, making you {{age}} years old. Is that correct? + +#### es-ES +Para confirmar: tu fecha de nacimiento es {{formatted}}, tienes {{age}} años. ¿Es correcto? + +### when_certain +#### pt-BR +Perfeito! Data de nascimento: {{formatted}} ({{age}} anos). + +#### en-US +Got it! Date of birth: {{formatted}} ({{age}} years old). + +#### es-ES +¡Perfecto! Fecha de nacimiento: {{formatted}} ({{age}} años). + +### when_invalid +#### pt-BR +Essa data não parece correta. Pode verificar e tentar novamente? Formato: DD/MM/AAAA + +#### en-US +That date doesn't seem right. Could you check and try again? Format: MM/DD/YYYY + +#### es-ES +Esa fecha no parece correcta. ¿Puedes verificar e intentar de nuevo? Formato: DD/MM/AAAA + +### when_minor +#### pt-BR +Atenção: o usuário parece ter menos de 18 anos ({{age}} anos). Verifique as regras do seu fluxo para menores de idade. + +#### en-US +Note: the user appears to be under 18 years old ({{age}} years old). Please check your flow's rules for minors. + +#### es-ES +Nota: el usuario parece ser menor de 18 años ({{age}} años). Verifique las reglas de su flujo para menores. diff --git a/src/Nalu.Api/Validators/validate_cancel_intent.md b/src/Nalu.Api/Validators/validate_cancel_intent.md new file mode 100644 index 0000000..f3c6a92 --- /dev/null +++ b/src/Nalu.Api/Validators/validate_cancel_intent.md @@ -0,0 +1,132 @@ +# validate_cancel_intent + +Detects cancellation intent — service/subscription vs current operation vs frustration threat. + +## config + +- type: extraction +- version: 1.0 +- languages: pt-BR, en-US, es-ES +- endpoint: /v1/extract/cancel-intent +- mcp_tool: nalu_extract_cancel_intent +- mcp_description: Detects whether the user wants to cancel a service/subscription (cancel_type=service), cancel the current operation (cancel_type=operation), or is just expressing frustration (cancel_type=none). Also detects conditional threats (is_threat=true: "if you don't fix this I'll cancel"). The bot developer decides the retention flow — NALU only classifies. Use has_suggestion to route the response. + +## deterministic_rules + +### stop_words +#### pt-BR +bom dia, boa tarde, olá, oi, tudo bem, ok, certo + +#### en-US +hello, hi, good morning, ok, alright, sure + +#### es-ES +hola, buenos días, ok, bien + +### reject_patterns +- ^[a-zA-Z\s]{1,4}$ + +### accept_patterns +- (cancelar plano|cancelar assinatura|quero cancelar|desistir do serviço|encerrar contrato|não quero mais|quero sair) +- (cancel my subscription|cancel my account|cancel my plan|i want to cancel|i'd like to cancel|cancel service|stop my subscription) +- (quiero cancelar|cancelar mi suscripción|cancelar mi cuenta|no quiero más) +- (cancela isso|volta|desfaz|para tudo|cancel that|undo|go back|stop) + +### constraints +- min_length: 3 + +## prompt + +You are a cancellation intent classifier. Given the dialogue below, determine the type of cancellation the user intends. + +Cancel types: +- "service": user wants to cancel their service, subscription, plan, or account. +- "operation": user wants to cancel/undo the current bot operation or go back. +- "none": user is not canceling — just frustrated or thinking about it. + +Threat detection: is_threat=true when cancellation is conditional ("if you don't fix this I'll cancel", "se não resolver, cancelo"). + +Rules: +1. "Quero cancelar meu plano" → service, is_threat: false. +2. "Cancela isso, volta pro menu" → operation, is_threat: false. +3. "Se não resolver vou cancelar tudo" → service, is_threat: true, certain: false. +4. "Tô pensando em cancelar..." → none, certain: false. +5. Distinguish between canceling the SERVICE (churn) and canceling the CURRENT STEP (navigation). + +Dialogue: +Agent: {{agent_input}} +User: {{user_input}} + +Agent context: {{agent_context}} + +Reply ONLY with valid JSON, no markdown: +{ + "extracted_value": "{\"cancel_type\":\"service/operation/none\",\"certainty_score\":0.0-1.0,\"is_threat\":true/false}", + "certain": true/false, + "reasoning": "short explanation" +} + +## few_shot_examples + +### example 1 +- agent_input: How can I help? +- user_input: I want to cancel my subscription +- output: {"extracted_value": "{\"cancel_type\":\"service\",\"certainty_score\":0.97,\"is_threat\":false}", "certain": true, "reasoning": "Direct service cancellation request"} + +### example 2 +- agent_input: Confirm your order? +- user_input: no cancel that, go back +- output: {"extracted_value": "{\"cancel_type\":\"operation\",\"certainty_score\":0.95,\"is_threat\":false}", "certain": true, "reasoning": "User canceling current operation, not the service"} + +### example 3 +- agent_input: I'm looking into it +- user_input: if you don't fix this I'm canceling everything +- output: {"extracted_value": "{\"cancel_type\":\"service\",\"certainty_score\":0.75,\"is_threat\":true}", "certain": false, "reasoning": "Conditional threat — not yet a firm decision to cancel"} + +### example 4 +- agent_input: Posso ajudar? +- user_input: tô pensando em cancelar minha assinatura +- output: {"extracted_value": "{\"cancel_type\":\"none\",\"certainty_score\":0.60,\"is_threat\":false}", "certain": false, "reasoning": "User considering cancellation but hasn't decided"} + +### example 5 +- agent_input: ¿En qué puedo ayudarte? +- user_input: quiero cancelar mi suscripción +- output: {"extracted_value": "{\"cancel_type\":\"service\",\"certainty_score\":0.98,\"is_threat\":false}", "certain": true, "reasoning": "Direct service cancellation in Spanish"} + +## post_processors +- select_cancel_suggestion + +## suggestions + +### when_cancel_service +#### pt-BR +Entendo que você quer cancelar. Antes de prosseguirmos, há algo que podemos resolver para que você fique? + +#### en-US +I understand you'd like to cancel. Before we proceed, is there anything we can resolve to keep you? + +#### es-ES +Entiendo que deseas cancelar. Antes de proceder, ¿hay algo que podamos resolver para que te quedes? + +### when_cancel_operation +#### pt-BR +Entendido! Vou cancelar essa etapa. O que você gostaria de fazer agora? + +#### en-US +Got it, I'll cancel that. What would you like to do instead? + +#### es-ES +Entendido. Voy a cancelar eso. ¿Qué te gustaría hacer ahora? + +### when_threat +#### pt-BR +Lamento os problemas. Vou fazer o meu melhor para resolver isso agora. + +#### en-US +I'm sorry for the trouble. Let me try to resolve this for you right now. + +#### es-ES +Lamento los problemas. Déjame intentar resolver esto ahora mismo. + +### when_not_cancel +(no suggestion — continue conversation) diff --git a/src/Nalu.Api/Validators/validate_cep.md b/src/Nalu.Api/Validators/validate_cep.md new file mode 100644 index 0000000..f59621f --- /dev/null +++ b/src/Nalu.Api/Validators/validate_cep.md @@ -0,0 +1,87 @@ +# validate_cep + +Extrai o CEP do usuário e consulta o endereço via ViaCEP. + +## config + +- type: extraction +- version: 1.0 +- languages: pt-BR +- endpoint: /v1/extract/cep +- mcp_tool: nalu_extract_cep +- mcp_description: Extrai o CEP do usuário e retorna o endereço completo (logradouro, bairro, cidade, estado). Se certain=true e suggestion_to_agent não é null, confirme o endereço com o usuário antes de prosseguir. Se obtained=false, o CEP não foi encontrado — use suggestion_to_agent para pedir novamente. + +## deterministic_rules + +### stop_words +bom dia, boa tarde, boa noite, olá, oi, não sei, não lembro + +### reject_patterns +- ^[a-zA-Z\s]+$ +- ^0{5}-?0{3}$ + +### accept_patterns +- (\d{5}-?\d{3}) +- (\d{8}) + +### constraints +- min_length: 8 + +## prompt + +Você é um extrator de CEP. Dado o diálogo abaixo, extraia o CEP que o usuário informou. + +Regras: +1. Extraia apenas os 8 dígitos numéricos do CEP. +2. Se o usuário não forneceu o CEP ou foi evasivo, retorne extracted_value: null. +3. Não valide o CEP — apenas extraia os dígitos. + +Diálogo: +Agente: {{agent_input}} +Usuário: {{user_input}} + +Contexto do agente: {{agent_context}} + +Responda SOMENTE com JSON válido, sem markdown, sem explicação: +{ + "extracted_value": "8 dígitos numéricos do CEP ou null", + "certain": true/false, + "reasoning": "explicação curta" +} + +## few_shot_examples + +### example 1 +- agent_input: Qual é o seu CEP? +- user_input: 01001-000 +- output: {"extracted_value": "01001000", "certain": true, "reasoning": "CEP no formato padrão"} + +### example 2 +- agent_input: Me diga seu CEP de entrega. +- user_input: Meu cep é 04538133 +- output: {"extracted_value": "04538133", "certain": true, "reasoning": "CEP extraído da frase"} + +### example 3 +- agent_input: Qual é o seu CEP? +- user_input: Não sei de cabeça +- output: {"extracted_value": null, "certain": false, "reasoning": "Usuário não forneceu o CEP"} + +## post_processors +- format_cep + +## enrichment +- viacep + +## suggestions + +### when_null_evasive +Preciso do seu CEP para identificar o endereço. Pode informar? Formato: XXXXX-XXX + +### when_uncertain +Encontrei o seguinte endereço: {{formatted_address}}. Está correto? + +### when_certain +Ótimo! Encontrei seu endereço: {{formatted_address}}. Posso usar esse endereço? + +### when_invalid +Esse CEP não foi encontrado ou é inválido. Pode verificar e digitar novamente? Formato: XXXXX-XXX diff --git a/src/Nalu.Api/Validators/validate_cnpj.md b/src/Nalu.Api/Validators/validate_cnpj.md new file mode 100644 index 0000000..fcf754c --- /dev/null +++ b/src/Nalu.Api/Validators/validate_cnpj.md @@ -0,0 +1,86 @@ +# validate_cnpj + +Extrai e valida CNPJ brasileiro (14 dígitos com algoritmo mod 11). + +## config + +- type: extraction +- version: 1.0 +- 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. + +## deterministic_rules + +### stop_words +bom dia, boa tarde, boa noite, olá, oi, não tenho, nao tenho, não sei, sem cnpj + +### reject_patterns +- ^[a-zA-Z\s]+$ +- ^(\d)\1{13}$ + +### accept_patterns +- (\d{2}[\.\s]?\d{3}[\.\s]?\d{3}[\/\s]?\d{4}[-\s]?\d{2}) +- (\d{14}) + +### constraints +- min_length: 14 + +## prompt + +Você é um extrator de CNPJ. Dado o diálogo abaixo, extraia o CNPJ que a empresa informou. + +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. + +Diálogo: +Agente: {{agent_input}} +Usuário: {{user_input}} + +Contexto do agente: {{agent_context}} + +Responda SOMENTE com JSON válido, sem markdown: +{ + "extracted_value": "14 dígitos do CNPJ ou null", + "certain": true/false, + "reasoning": "explicação curta" +} + +## few_shot_examples + +### example 1 +- agent_input: Qual o CNPJ da empresa? +- user_input: 11.222.333/0001-81 +- output: {"extracted_value": "11222333000181", "certain": true, "reasoning": "CNPJ no formato padrão"} + +### example 2 +- agent_input: Informe o CNPJ. +- user_input: é 11222333000181 +- output: {"extracted_value": "11222333000181", "certain": true, "reasoning": "CNPJ sem formatação"} + +### example 3 +- agent_input: Qual o CNPJ da empresa? +- user_input: não tenho aqui agora +- output: {"extracted_value": null, "certain": false, "reasoning": "Usuário não forneceu o CNPJ"} + +### example 4 +- agent_input: CNPJ da empresa? +- user_input: 11.111.111/1111-11 +- output: {"extracted_value": "11111111111111", "certain": true, "reasoning": "CNPJ extraído — validação de dígitos feita pelo pós-processador"} + +## post_processors +- validate_cnpj_digit + +## suggestions + +### when_null_evasive +Preciso do CNPJ da empresa para continuar. Pode informar? São 14 dígitos (formato: XX.XXX.XXX/XXXX-XX) + +### when_invalid +Esse CNPJ parece estar incorreto. Pode verificar? São 14 dígitos (XX.XXX.XXX/XXXX-XX). + +### when_certain +(sem sugestão — agente segue o fluxo) diff --git a/src/Nalu.Api/Validators/validate_company_name.md b/src/Nalu.Api/Validators/validate_company_name.md new file mode 100644 index 0000000..bcbf528 --- /dev/null +++ b/src/Nalu.Api/Validators/validate_company_name.md @@ -0,0 +1,111 @@ +# validate_company_name + +Extracts company name from conversation, detecting legal suffixes and trade names. + +## config + +- type: extraction +- version: 1.0 +- languages: pt-BR, en-US, es-ES +- endpoint: /v1/extract/company-name +- mcp_tool: nalu_extract_company_name +- mcp_description: Extracts company name from conversation. Detects legal suffixes (LTDA, ME, S/A, LLC, Inc, GmbH, etc.) and separates the clean name from the suffix. Returns company_name, suffix, and clean_name. Works for any country with special handling for Brazilian company suffixes. + +## deterministic_rules + +### stop_words +#### pt-BR +não sei, não lembro, bom dia, boa tarde, olá, oi, pessoa física, autônomo, sem empresa + +#### en-US +i don't know, i don't remember, hello, hi, no company, individual, freelancer + +#### es-ES +no sé, no recuerdo, hola, persona física, autónomo, sin empresa + +### reject_patterns +- ^(sim|não|yes|no|sí|ok)$ +- ^[0-9\s\.\-]+$ + +### accept_patterns +- (\b\w[\w\s\.&,'-]{2,}(?:ltda|me|epp|eireli|s[/\.]?a|sa|llc|inc|corp|ltd|gmbh|bv|srl|sl)\b) +- (\b[A-Z][a-zA-Z\s&,'-]{3,}\b) + +### constraints +- min_length: 3 +- must_have_alpha: true + +## prompt + +You are a company name extractor. Given the dialogue below, extract the company name. + +Rules: +1. Extract the full company name including legal suffix (LTDA, LLC, Inc, etc.) if present. +2. Separate the clean name from the suffix. +3. Capitalize properly (Title Case for most names, preserve ALL CAPS if that appears intentional). +4. Remove context phrases: "a empresa", "a firma", "o nome é", "the company is", "trabajo en", etc. +5. Recognized suffixes: LTDA, ME, EPP, EIRELI, S/A, S.A., SA, LLC, Inc, Corp, Ltd, GmbH, BV, SRL, SL. +6. If the user gave an informal reference ("trabalho na tech solutions"), extract "Tech Solutions". +7. If no company name was provided, return extracted_value: null. + +Dialogue: +Agent: {{agent_input}} +User: {{user_input}} + +Agent context: {{agent_context}} + +Reply ONLY with valid JSON, no markdown: +{ + "extracted_value": "{\"company_name\":\"Full Name Ltda\",\"suffix\":\"LTDA\",\"clean_name\":\"Full Name\"}", + "certain": true/false, + "reasoning": "short explanation" +} + +## few_shot_examples + +### example 1 +- agent_input: What's the company name? +- user_input: Tech Solutions Ltda +- output: {"extracted_value": "{\"company_name\":\"Tech Solutions Ltda\",\"suffix\":\"LTDA\",\"clean_name\":\"Tech Solutions\"}", "certain": true, "reasoning": "Company name with Brazilian suffix LTDA"} + +### example 2 +- agent_input: Qual a empresa? +- user_input: trabalho na tech solutions +- output: {"extracted_value": "{\"company_name\":\"Tech Solutions\",\"suffix\":null,\"clean_name\":\"Tech Solutions\"}", "certain": true, "reasoning": "Company name extracted from informal sentence"} + +### example 3 +- agent_input: Company name? +- user_input: Acme Corporation Inc. +- output: {"extracted_value": "{\"company_name\":\"Acme Corporation Inc.\",\"suffix\":\"INC\",\"clean_name\":\"Acme Corporation\"}", "certain": true, "reasoning": "US company with Inc suffix"} + +### example 4 +- agent_input: Nome da empresa? +- user_input: ah deixa pra lá +- output: {"extracted_value": null, "certain": false, "reasoning": "User refused to provide company name"} + +### example 5 +- agent_input: What is your company's name? +- user_input: GlobalTech GmbH +- output: {"extracted_value": "{\"company_name\":\"GlobalTech GmbH\",\"suffix\":\"GMBH\",\"clean_name\":\"GlobalTech\"}", "certain": true, "reasoning": "German GmbH suffix detected"} + +## suggestions + +### when_null_evasive +#### pt-BR +Preciso do nome da empresa para continuar. Qual é o nome? (Ex: Tech Solutions Ltda) + +#### en-US +I need the company name to continue. What's the company's name? (e.g. Tech Solutions Inc.) + +#### es-ES +Necesito el nombre de la empresa para continuar. ¿Cuál es? (Ej: Tech Solutions S.L.) + +### when_uncertain +#### pt-BR +O nome da empresa é {{company_name}}? Pode confirmar? + +#### en-US +The company name is {{company_name}}? Can you confirm? + +#### es-ES +¿El nombre de la empresa es {{company_name}}? ¿Puedes confirmar? diff --git a/src/Nalu.Api/Validators/validate_cpf.md b/src/Nalu.Api/Validators/validate_cpf.md new file mode 100644 index 0000000..c803b5b --- /dev/null +++ b/src/Nalu.Api/Validators/validate_cpf.md @@ -0,0 +1,98 @@ +# validate_cpf + +Extrai e valida o CPF do usuário a partir do diálogo. + +## config + +- type: extraction +- version: 1.0 +- languages: pt-BR +- endpoint: /v1/extract/cpf +- mcp_tool: nalu_extract_cpf +- mcp_description: Extrai e valida o CPF do usuário. Valida dígitos verificadores automaticamente. Se certain=true, o CPF é válido e pode ser usado. 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 CPF. + +## deterministic_rules + +### stop_words +bom dia, boa tarde, boa noite, olá, oi + +### reject_patterns +- ^(não lembro|nao lembro|esqueci|sem cpf|não tenho|nao tenho|não sei|nao sei)$ +- ^[a-zA-Z\s]+$ + +### accept_patterns +- (\d{3}\.?\d{3}\.?\d{3}-?\d{2}) + +### constraints +- min_length: 11 + +## prompt + +Você é um extrator de CPF. Dado o diálogo abaixo, extraia o CPF que o usuário informou. + +Regras: +1. Extraia apenas os 11 dígitos numéricos — ignore pontuação e espaços. +2. Se o usuário disser o CPF por extenso ("um dois três..."), converta para dígitos. +3. Se o usuário foi evasivo ou não forneceu o CPF, retorne extracted_value: null. +4. Não valide os dígitos verificadores — apenas extraia os dígitos. +5. Se a resposta contiver 11 dígitos numéricos, extraia-os mesmo que estejam no meio de uma frase. + +Diálogo: +Agente: {{agent_input}} +Usuário: {{user_input}} + +Contexto do agente: {{agent_context}} + +Responda SOMENTE com JSON válido, sem markdown, sem explicação: +{ + "extracted_value": "11 dígitos numéricos ou null", + "certain": true/false, + "reasoning": "explicação curta" +} + +## few_shot_examples + +### example 1 +- agent_input: Qual é o seu CPF? +- user_input: 123.456.789-09 +- output: {"extracted_value": "12345678909", "certain": true, "reasoning": "CPF no formato padrão"} + +### example 2 +- agent_input: Preciso do seu CPF para continuar. +- user_input: Pode ser, meu cpf é 048 867 206 97 +- output: {"extracted_value": "04886720697", "certain": true, "reasoning": "CPF extraído do meio da frase"} + +### example 3 +- agent_input: Qual é o seu CPF? +- user_input: Não lembro o número +- output: {"extracted_value": null, "certain": false, "reasoning": "Usuário não forneceu o CPF"} + +### example 4 +- agent_input: Me informe seu CPF. +- user_input: zero quatro oito oito seis sete dois zero seis nove sete +- output: {"extracted_value": "04886720697", "certain": true, "reasoning": "CPF por extenso convertido para dígitos"} + +### example 5 +- agent_input: Qual seu CPF? +- user_input: 111.111.111-11 +- output: {"extracted_value": "11111111111", "certain": false, "reasoning": "Sequência repetida, provável erro ou teste"} + +## post_processors +- validate_cpf_digit + +## enrichment +(nenhum) + +## suggestions + +### when_null_evasive +Preciso do seu CPF para continuar. São 11 dígitos, pode digitar? + +### when_invalid +Esse CPF parece estar incorreto (dígitos verificadores não conferem). Pode verificar e digitar novamente? + +### when_uncertain +Só confirmando: seu CPF é {{extracted_value}}? Pode confirmar? + +### when_certain +(sem sugestão — agente segue o fluxo) diff --git a/src/Nalu.Api/Validators/validate_email.md b/src/Nalu.Api/Validators/validate_email.md new file mode 100644 index 0000000..dd8086b --- /dev/null +++ b/src/Nalu.Api/Validators/validate_email.md @@ -0,0 +1,101 @@ +# validate_email + +Extrai email do usuário com correção automática de typos em domínios comuns. + +## config + +- type: extraction +- version: 1.0 +- languages: pt-BR, en-US, es-ES +- endpoint: /v1/extract/email +- mcp_tool: nalu_extract_email +- mcp_description: Extrai o email do usuário com correção automática de typos em domínios comuns (gmail, hotmail, outlook). Se houve correção, certain=false e suggestion_to_agent pede confirmação. Se certain=true, o email está confirmado. Se obtained=false, use a sugestão para re-pedir. + +## deterministic_rules + +### stop_words +bom dia, boa tarde, boa noite, olá, oi, não tenho, não uso email + +### reject_patterns +- ^[^@\s]+$ +- ^(não|nao|sem|nenhum)$ + +### accept_patterns +- ([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}) + +### constraints +- min_length: 5 +- must_have_alpha: true + +## prompt + +Você é um extrator de email. Dado o diálogo abaixo, extraia o endereço de email que o usuário informou. + +Regras: +1. Extraia o email exatamente como o usuário digitou. +2. Se o usuário não forneceu email ou foi evasivo, retorne extracted_value: null. +3. Converta para letras minúsculas. +4. Não corrija typos — retorne o email como está. + +Diálogo: +Agente: {{agent_input}} +Usuário: {{user_input}} + +Contexto do agente: {{agent_context}} + +Responda SOMENTE com JSON válido, sem markdown, sem explicação: +{ + "extracted_value": "email extraído ou null", + "certain": true/false, + "reasoning": "explicação curta" +} + +## few_shot_examples + +### example 1 +- agent_input: Qual é o seu email? +- user_input: joao.silva@gmail.com +- output: {"extracted_value": "joao.silva@gmail.com", "certain": true, "reasoning": "Email válido informado diretamente"} + +### example 2 +- agent_input: Me passe seu email. +- user_input: Pode ser, é maria@gamil.com +- output: {"extracted_value": "maria@gamil.com", "certain": true, "reasoning": "Email extraído do meio da frase"} + +### example 3 +- agent_input: Qual seu email? +- user_input: Não tenho email não +- output: {"extracted_value": null, "certain": false, "reasoning": "Usuário declarou não ter email"} + +### example 4 +- agent_input: Pode me informar seu email? +- user_input: pedro arroba hotmail ponto com +- output: {"extracted_value": "pedro@hotmail.com", "certain": true, "reasoning": "Email dito por extenso convertido"} + +### example 5 +- agent_input: Qual é o email para contato? +- user_input: ana.costa@outlook.con +- output: {"extracted_value": "ana.costa@outlook.con", "certain": false, "reasoning": "Possível typo em .con"} + +## post_processors +- correct_email_typos + +## enrichment +(nenhum) + +## suggestions + +### when_null_evasive +Preciso do seu email para continuar. Pode informar? + +### when_corrected +Seu email é {{extracted_value}}? (identificamos um possível erro de digitação em '{{original}}') + +### when_uncertain +Seu email é {{extracted_value}}? Pode confirmar? + +### when_invalid +Esse email não parece válido. Pode digitar novamente? + +### when_certain +(sem sugestão — agente segue o fluxo) diff --git a/src/Nalu.Api/Validators/validate_full_name.md b/src/Nalu.Api/Validators/validate_full_name.md new file mode 100644 index 0000000..83c90db --- /dev/null +++ b/src/Nalu.Api/Validators/validate_full_name.md @@ -0,0 +1,110 @@ +# validate_full_name + +Extrai o nome completo do usuário 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. + +## deterministic_rules + +### stop_words +bom dia, boa tarde, boa noite, olá, oi, tudo bem, e aí, fala, eae, opa + +### reject_patterns +- ^(não|nao|sei la|sei lá|tanto faz|qualquer|nenhum|nada)$ +- ^\d+$ + +### accept_patterns +- ^meu nome é\s+(.+)$ +- ^me chamo\s+(.+)$ +- ^sou o\s+(.+)$ +- ^sou a\s+(.+)$ +- ^pode me chamar de\s+(.+)$ + +### constraints +- min_length: 2 +- must_have_alpha: 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. + +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). + +Diálogo: +Agente: {{agent_input}} +Usuário: {{user_input}} + +Contexto do agente: {{agent_context}} + +Responda SOMENTE com JSON válido, sem markdown, sem explicação: +{ + "extracted_value": "nome extraído ou null", + "certain": true/false, + "reasoning": "explicação curta de 1 linha" +} + +## few_shot_examples + +### example 1 +- agent_input: Bom dia! Qual seu nome completo? +- user_input: Bom dia! +- 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"} + +### 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"} + +### example 4 +- 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"} + +## post_processors +- capitalize_proper_name +- remove_titles + +## enrichment +(nenhum) + +## suggestions + +### when_null_greeting +{{greeting_response}} Mas preciso do seu nome completo para continuar. Pode me dizer? + +### when_null_evasive +Sem problemas, mas preciso do seu nome para prosseguir. Qual seu nome completo? + +### when_uncertain +Só confirmando: seu nome é {{extracted_value}}? Pode confirmar? + +### when_certain +(sem sugestão — agente segue o fluxo) diff --git a/src/Nalu.Api/Validators/validate_handoff.md b/src/Nalu.Api/Validators/validate_handoff.md new file mode 100644 index 0000000..370f248 --- /dev/null +++ b/src/Nalu.Api/Validators/validate_handoff.md @@ -0,0 +1,124 @@ +# validate_handoff + +Detects when the user wants to speak with a human agent. + +## config + +- type: extraction +- version: 1.0 +- languages: pt-BR, en-US, es-ES +- endpoint: /v1/extract/handoff +- mcp_tool: nalu_extract_handoff +- mcp_description: Detects when the user wants to speak with a human agent. Returns wants_human=true with urgency (low/medium/high) and frustration_detected. The bot developer decides what action to take — NALU only classifies. If obtained=true, check suggestion_to_agent for the recommended response script. Use has_suggestion to branch your flow. + +## deterministic_rules + +### stop_words +#### pt-BR +bom dia, boa tarde, boa noite, olá, oi, tudo bem, ok, certo + +#### en-US +hello, hi, good morning, good afternoon, ok, alright, sure + +#### es-ES +hola, buenos días, buenas tardes, ok, bien + +### reject_patterns +- ^[a-zA-Z\s]{1,3}$ + +### accept_patterns +- (quero falar com atendente|me transfere|cadê o supervisor|chega de robô|quero humano|falar com gente|passa pra alguém|não quero falar com robô|me passa pra|falar com uma pessoa|atendente humano|suporte humano|operador) +- (talk to a human|speak to an agent|transfer me|i want a real person|connect me to support|let me speak to someone|human agent|representative|operator|stop talking to a bot) +- (quiero hablar con|pásame con un agente|quiero un humano|operador|atención humana|no quiero hablar con un robot) + +### constraints +- min_length: 3 + +## prompt + +You are an intent classifier for human handoff detection. Given the dialogue below, determine whether the user wants to speak with a human agent. + +Rules: +1. Detect direct requests: "quero falar com atendente", "transfer me to an agent", "I want a real person". +2. Detect frustrated indirect requests: "isso não está resolvendo nada", "vocês não ajudam", "esse chat é inútil" — these imply wanting a human even without explicit request. +3. Assess urgency: high (caps, exclamation marks, repeated request, profanity), medium (direct calm request), low (polite optional request). +4. Set frustration_detected=true if the user shows anger, uses caps, or has been repeating the same request. +5. If the user does NOT want a human, return wants_human: false. + +Dialogue: +Agent: {{agent_input}} +User: {{user_input}} + +Agent context: {{agent_context}} + +Reply ONLY with valid JSON, no markdown: +{ + "extracted_value": "{\"wants_human\":true/false,\"urgency\":\"low/medium/high\",\"frustration_detected\":true/false}", + "certain": true/false, + "reasoning": "short explanation" +} + +## few_shot_examples + +### example 1 +- agent_input: How can I help? +- user_input: I want to talk to a real person +- output: {"extracted_value": "{\"wants_human\":true,\"urgency\":\"medium\",\"frustration_detected\":false}", "certain": true, "reasoning": "Direct request for human agent"} + +### example 2 +- agent_input: Let me check that for you +- user_input: ENOUGH! Transfer me to someone NOW!!! +- output: {"extracted_value": "{\"wants_human\":true,\"urgency\":\"high\",\"frustration_detected\":true}", "certain": true, "reasoning": "High urgency: caps, exclamation, explicit transfer request"} + +### example 3 +- agent_input: Posso ajudar em algo mais? +- user_input: isso não tá resolvendo nada +- output: {"extracted_value": "{\"wants_human\":true,\"urgency\":\"medium\",\"frustration_detected\":true}", "certain": false, "reasoning": "Indirect signal of frustration implying desire for human help"} + +### example 4 +- agent_input: Is there anything else I can help with? +- user_input: seria possível falar com alguém da equipe? +- output: {"extracted_value": "{\"wants_human\":true,\"urgency\":\"low\",\"frustration_detected\":false}", "certain": true, "reasoning": "Polite low-urgency request"} + +### example 5 +- agent_input: Can I assist you further? +- user_input: no you can help me, go ahead +- output: {"extracted_value": "{\"wants_human\":false,\"urgency\":\"low\",\"frustration_detected\":false}", "certain": true, "reasoning": "User explicitly declined human transfer"} + +## post_processors +- select_handoff_suggestion + +## suggestions + +### when_handoff_high +#### pt-BR +Entendo sua frustração. Deixa eu te transferir agora para um atendente humano. + +#### en-US +I understand your frustration. Let me transfer you to a human agent right away. + +#### es-ES +Entiendo tu frustración. Permíteme transferirte a un agente humano ahora mismo. + +### when_handoff_medium +#### pt-BR +Posso te transferir para um atendente humano. Você gostaria? + +#### en-US +I can transfer you to a human agent. Would you like that? + +#### es-ES +Puedo transferirte a un agente humano. ¿Te gustaría eso? + +### when_handoff_low +#### pt-BR +Se preferir falar com alguém da nossa equipe, posso providenciar isso. + +#### en-US +If you'd prefer to speak with someone from our team, I can arrange that. + +#### es-ES +Si prefieres hablar con alguien de nuestro equipo, puedo organizarlo. + +### when_not_handoff +(no suggestion — continue conversation) diff --git a/src/Nalu.Api/Validators/validate_phone_br.md b/src/Nalu.Api/Validators/validate_phone_br.md new file mode 100644 index 0000000..2257f3e --- /dev/null +++ b/src/Nalu.Api/Validators/validate_phone_br.md @@ -0,0 +1,100 @@ +# validate_phone_br + +Extrai telefone brasileiro com DDD a partir do diálogo. + +## config + +- type: extraction +- version: 1.0 +- languages: pt-BR +- endpoint: /v1/extract/phone +- mcp_tool: nalu_extract_phone +- mcp_description: Extrai o número de telefone brasileiro do usuário, incluindo DDD. Formata automaticamente. Se certain=true, o número é válido e formatado. Se obtained=false, use suggestion_to_agent para pedir novamente. + +## deterministic_rules + +### stop_words +bom dia, boa tarde, boa noite, olá, oi, não tenho, nao tenho, sem telefone + +### reject_patterns +- ^[a-zA-Z\s]+$ +- ^(não|nao|sem|nenhum)$ + +### accept_patterns +- (\+?55\s?\(?\d{2}\)?\s?\d{4,5}[-\s]?\d{4}) +- (\(?\d{2}\)?\s?\d{4,5}[-\s]?\d{4}) +- (? +/// One document per (api_key + date). Counters updated atomically via $inc. +/// +public class UsageDaily +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); + + [BsonElement("api_key")] + public required string ApiKey { get; set; } + + [BsonElement("plan")] + public required string Plan { get; set; } + + /// Format: "YYYY-MM-DD" + [BsonElement("date")] + public required string Date { get; set; } + + [BsonElement("daily_count")] + public int DailyCount { get; set; } + + [BsonElement("monthly_count")] + public int MonthlyCount { get; set; } + + [BsonElement("year_month")] + public required string YearMonth { get; set; } // "YYYY-MM" +} diff --git a/src/Nalu.Web/Data/Models/UsageMonthly.cs b/src/Nalu.Web/Data/Models/UsageMonthly.cs new file mode 100644 index 0000000..a2817fb --- /dev/null +++ b/src/Nalu.Web/Data/Models/UsageMonthly.cs @@ -0,0 +1,42 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Nalu.Web.Data.Models; + +/// +/// One document per (api_key + year_month). Primary record for credit-based rate limiting. +/// All counters updated atomically via $inc. Upserted on first call of each month. +/// +public class UsageMonthly +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); + + [BsonElement("api_key")] + public required string ApiKey { get; set; } + + [BsonElement("plan")] + public required string Plan { get; set; } + + /// "YYYY-MM" + [BsonElement("year_month")] + public required string YearMonth { get; set; } + + [BsonElement("total_credits_used")] + public int TotalCreditsUsed { get; set; } + + [BsonElement("total_requests")] + public int TotalRequests { get; set; } + + /// validator_id → credits consumed + [BsonElement("credits_by_validator")] + public Dictionary CreditsByValidator { get; set; } = new(); + + /// validator_id → request count + [BsonElement("requests_by_validator")] + public Dictionary RequestsByValidator { get; set; } = new(); + + [BsonElement("updated_at")] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Nalu.Web/Data/Models/WebhookEvent.cs b/src/Nalu.Web/Data/Models/WebhookEvent.cs new file mode 100644 index 0000000..c054b18 --- /dev/null +++ b/src/Nalu.Web/Data/Models/WebhookEvent.cs @@ -0,0 +1,27 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Nalu.Web.Data.Models; + +/// +/// Stripe webhook idempotency table. One document per Stripe event ID. +/// +public class WebhookEvent +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); + + [BsonElement("stripe_event_id")] + public required string StripeEventId { get; set; } + + [BsonElement("event_type")] + public required string EventType { get; set; } + + /// "processed" | "failed" | "ignored" + [BsonElement("status")] + public string Status { get; set; } = "processed"; + + [BsonElement("processed_at")] + public DateTime ProcessedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Nalu.Web/Data/MongoDbContext.cs b/src/Nalu.Web/Data/MongoDbContext.cs new file mode 100644 index 0000000..3df97bd --- /dev/null +++ b/src/Nalu.Web/Data/MongoDbContext.cs @@ -0,0 +1,126 @@ +using MongoDB.Driver; +using Nalu.Web.Data.Models; + +namespace Nalu.Web.Data; + +public class MongoDbContext +{ + private readonly IMongoDatabase? _database; + + public MongoDbContext(IConfiguration configuration, IMongoClient? mongoClient = null) + { + var connectionString = configuration.GetConnectionString("MongoDB"); + + if (mongoClient is not null && !string.IsNullOrWhiteSpace(connectionString)) + { + try + { + var url = MongoUrl.Create(connectionString); + _database = mongoClient.GetDatabase(url.DatabaseName); + IsConnected = true; + } + catch + { + IsConnected = false; + } + } + } + + public bool IsConnected { get; } + + public IMongoCollection Users => + _database!.GetCollection("users"); + + public IMongoCollection ApiKeys => + _database!.GetCollection("api_keys"); + + public IMongoCollection Subscriptions => + _database!.GetCollection("subscriptions"); + + public IMongoCollection UsageDaily => + _database!.GetCollection("usage_daily"); + + public IMongoCollection WebhookEvents => + _database!.GetCollection("webhook_events"); + + public IMongoCollection UsageMonthly => + _database!.GetCollection("usage_monthly"); + + public IMongoCollection CepCache => + _database!.GetCollection("cep_cache"); + + public async Task InitializeAsync() + { + if (!IsConnected) return; + await CreateIndexesAsync(); + } + + private async Task CreateIndexesAsync() + { + // users + await Users.Indexes.CreateManyAsync([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(u => u.Email), + new CreateIndexOptions { Unique = true }), + new CreateIndexModel( + Builders.IndexKeys + .Ascending(u => u.Provider) + .Ascending(u => u.ProviderId), + new CreateIndexOptions { Unique = true }), + ]); + + // api_keys + await ApiKeys.Indexes.CreateManyAsync([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(k => k.Key), + new CreateIndexOptions { Unique = true }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(k => k.UserId)), + ]); + + // subscriptions + await Subscriptions.Indexes.CreateManyAsync([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(s => s.StripeSubscriptionId), + new CreateIndexOptions { Unique = true }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(s => s.UserId)), + ]); + + // usage_daily — compound (api_key + date) unique for atomic $inc upserts + await UsageDaily.Indexes.CreateManyAsync([ + new CreateIndexModel( + Builders.IndexKeys + .Ascending(u => u.ApiKey) + .Ascending(u => u.Date), + new CreateIndexOptions { Unique = true }), + new CreateIndexModel( + Builders.IndexKeys + .Ascending(u => u.ApiKey) + .Ascending(u => u.YearMonth)), + ]); + + // webhook_events — unique on stripe_event_id to prevent duplicate processing + await WebhookEvents.Indexes.CreateManyAsync([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(w => w.StripeEventId), + new CreateIndexOptions { Unique = true }), + ]); + + // usage_monthly — compound (api_key + year_month) unique for atomic $inc upserts + await UsageMonthly.Indexes.CreateManyAsync([ + new CreateIndexModel( + Builders.IndexKeys + .Ascending(u => u.ApiKey) + .Ascending(u => u.YearMonth), + new CreateIndexOptions { Unique = true }), + ]); + + // cep_cache — unique on cep (8 digits), for O(1) lookup + await CepCache.Indexes.CreateManyAsync([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(c => c.Cep), + new CreateIndexOptions { Unique = true }), + ]); + } +} diff --git a/src/Nalu.Web/Data/Repositories/ApiKeyRepository.cs b/src/Nalu.Web/Data/Repositories/ApiKeyRepository.cs new file mode 100644 index 0000000..03811f4 --- /dev/null +++ b/src/Nalu.Web/Data/Repositories/ApiKeyRepository.cs @@ -0,0 +1,48 @@ +using MongoDB.Driver; +using Nalu.Web.Data.Models; + +namespace Nalu.Web.Data.Repositories; + +public class ApiKeyRepository(MongoDbContext db) +{ + public async Task FindAsync(string key, CancellationToken ct = default) + { + if (!db.IsConnected) return null; + + return await db.ApiKeys + .Find(k => k.Key == key && k.IsActive) + .FirstOrDefaultAsync(ct); + } + + public async Task TouchAsync(string key, CancellationToken ct = default) + { + if (!db.IsConnected) return; + + await db.ApiKeys.UpdateOneAsync( + k => k.Key == key, + Builders.Update.Set(k => k.LastUsedAt, DateTime.UtcNow), + cancellationToken: ct); + } + + public async Task InsertAsync(ApiKey apiKey, CancellationToken ct = default) + { + await db.ApiKeys.InsertOneAsync(apiKey, cancellationToken: ct); + } + + public async Task> GetByUserAsync(string userId, CancellationToken ct = default) + { + if (!db.IsConnected) return []; + + return await db.ApiKeys + .Find(k => k.UserId == userId && k.IsActive) + .ToListAsync(ct); + } + + public async Task RevokeAsync(string key, CancellationToken ct = default) + { + await db.ApiKeys.UpdateOneAsync( + k => k.Key == key, + Builders.Update.Set(k => k.IsActive, false), + cancellationToken: ct); + } +} diff --git a/src/Nalu.Web/Data/Repositories/SubscriptionRepository.cs b/src/Nalu.Web/Data/Repositories/SubscriptionRepository.cs new file mode 100644 index 0000000..a77ca60 --- /dev/null +++ b/src/Nalu.Web/Data/Repositories/SubscriptionRepository.cs @@ -0,0 +1,46 @@ +using MongoDB.Driver; +using Nalu.Web.Data.Models; + +namespace Nalu.Web.Data.Repositories; + +public class SubscriptionRepository(MongoDbContext db) +{ + public async Task FindByUserAsync(string userId, CancellationToken ct = default) + { + if (!db.IsConnected) return null; + + return await db.Subscriptions + .Find(s => s.UserId == userId && s.Status == "active") + .FirstOrDefaultAsync(ct); + } + + public async Task FindByStripeIdAsync(string stripeSubscriptionId, CancellationToken ct = default) + { + if (!db.IsConnected) return null; + + return await db.Subscriptions + .Find(s => s.StripeSubscriptionId == stripeSubscriptionId) + .FirstOrDefaultAsync(ct); + } + + public async Task UpsertAsync(Subscription subscription, CancellationToken ct = default) + { + subscription.UpdatedAt = DateTime.UtcNow; + + await db.Subscriptions.ReplaceOneAsync( + s => s.StripeSubscriptionId == subscription.StripeSubscriptionId, + subscription, + new ReplaceOptions { IsUpsert = true }, + ct); + } + + public async Task UpdateStatusAsync(string stripeSubscriptionId, string status, CancellationToken ct = default) + { + await db.Subscriptions.UpdateOneAsync( + s => s.StripeSubscriptionId == stripeSubscriptionId, + Builders.Update + .Set(s => s.Status, status) + .Set(s => s.UpdatedAt, DateTime.UtcNow), + cancellationToken: ct); + } +} diff --git a/src/Nalu.Web/Data/Repositories/UsageRepository.cs b/src/Nalu.Web/Data/Repositories/UsageRepository.cs new file mode 100644 index 0000000..7856644 --- /dev/null +++ b/src/Nalu.Web/Data/Repositories/UsageRepository.cs @@ -0,0 +1,68 @@ +using MongoDB.Driver; +using Nalu.Web.Data.Models; + +namespace Nalu.Web.Data.Repositories; + +public class UsageRepository(MongoDbContext db) +{ + /// + /// Atomically increments daily and monthly counters. + /// Returns (dailyCount, monthlyCount) AFTER increment. + /// Returns null if MongoDB is not connected. + /// + public async Task<(int Daily, int Monthly)?> IncrementAsync( + string apiKey, string plan, CancellationToken ct = default) + { + if (!db.IsConnected) return null; + + var now = DateTime.UtcNow; + var date = now.ToString("yyyy-MM-dd"); + var yearMonth = now.ToString("yyyy-MM"); + + // Upsert: if doc exists increment, else create with count=1 + var filter = Builders.Filter.And( + Builders.Filter.Eq(u => u.ApiKey, apiKey), + Builders.Filter.Eq(u => u.Date, date)); + + var update = Builders.Update + .Inc(u => u.DailyCount, 1) + .Inc(u => u.MonthlyCount, 1) + .SetOnInsert(u => u.Plan, plan) + .SetOnInsert(u => u.YearMonth, yearMonth); + + var options = new FindOneAndUpdateOptions + { + IsUpsert = true, + ReturnDocument = ReturnDocument.After + }; + + var doc = await db.UsageDaily.FindOneAndUpdateAsync(filter, update, options, ct); + return (doc.DailyCount, doc.MonthlyCount); + } + + /// + /// Returns (dailyCount, monthlyCount) for the current period without modifying. + /// + public async Task<(int Daily, int Monthly)> GetCurrentUsageAsync( + string apiKey, CancellationToken ct = default) + { + if (!db.IsConnected) return (0, 0); + + var now = DateTime.UtcNow; + var date = now.ToString("yyyy-MM-dd"); + var yearMonth = now.ToString("yyyy-MM"); + + var doc = await db.UsageDaily + .Find(u => u.ApiKey == apiKey && u.Date == date) + .FirstOrDefaultAsync(ct); + + if (doc is null) return (0, 0); + + // Sum all docs in current month for monthly total + var monthlyDocs = await db.UsageDaily + .Find(u => u.ApiKey == apiKey && u.YearMonth == yearMonth) + .ToListAsync(ct); + + return (doc.DailyCount, monthlyDocs.Sum(d => d.DailyCount)); + } +} diff --git a/src/Nalu.Web/Data/Repositories/UserRepository.cs b/src/Nalu.Web/Data/Repositories/UserRepository.cs new file mode 100644 index 0000000..e221eda --- /dev/null +++ b/src/Nalu.Web/Data/Repositories/UserRepository.cs @@ -0,0 +1,61 @@ +using MongoDB.Driver; +using Nalu.Web.Data.Models; + +namespace Nalu.Web.Data.Repositories; + +public class UserRepository(MongoDbContext db) +{ + public async Task FindByEmailAsync(string email, CancellationToken ct = default) + { + if (!db.IsConnected) return null; + + return await db.Users + .Find(u => u.Email == email && u.IsActive) + .FirstOrDefaultAsync(ct); + } + + public async Task FindByProviderAsync(string provider, string providerId, CancellationToken ct = default) + { + if (!db.IsConnected) return null; + + return await db.Users + .Find(u => u.Provider == provider && u.ProviderId == providerId) + .FirstOrDefaultAsync(ct); + } + + public async Task FindByIdAsync(string id, CancellationToken ct = default) + { + if (!db.IsConnected) return null; + + return await db.Users + .Find(u => u.Id == id) + .FirstOrDefaultAsync(ct); + } + + public async Task UpsertAsync(NaluUser user, CancellationToken ct = default) + { + await db.Users.ReplaceOneAsync( + u => u.Provider == user.Provider && u.ProviderId == user.ProviderId, + user, + new ReplaceOptions { IsUpsert = true }, + ct); + } + + public async Task UpdatePlanAsync(string userId, string plan, CancellationToken ct = default) + { + await db.Users.UpdateOneAsync( + u => u.Id == userId, + Builders.Update + .Set(u => u.Plan, plan) + .Set(u => u.LastLoginAt, DateTime.UtcNow), + cancellationToken: ct); + } + + public async Task SetStripeCustomerAsync(string userId, string stripeCustomerId, CancellationToken ct = default) + { + await db.Users.UpdateOneAsync( + u => u.Id == userId, + Builders.Update.Set(u => u.StripeCustomerId, stripeCustomerId), + cancellationToken: ct); + } +} diff --git a/src/Nalu.Web/Data/Repositories/WebhookEventRepository.cs b/src/Nalu.Web/Data/Repositories/WebhookEventRepository.cs new file mode 100644 index 0000000..135a762 --- /dev/null +++ b/src/Nalu.Web/Data/Repositories/WebhookEventRepository.cs @@ -0,0 +1,40 @@ +using MongoDB.Driver; +using Nalu.Web.Data.Models; + +namespace Nalu.Web.Data.Repositories; + +public class WebhookEventRepository(MongoDbContext db) +{ + /// + /// Attempts to record a Stripe event for idempotency. + /// Returns true if inserted (first time seen), false if already processed. + /// + public async Task TryInsertAsync(string stripeEventId, string eventType, CancellationToken ct = default) + { + if (!db.IsConnected) return true; // fallback: allow processing if DB unavailable + + try + { + await db.WebhookEvents.InsertOneAsync(new WebhookEvent + { + StripeEventId = stripeEventId, + EventType = eventType + }, cancellationToken: ct); + + return true; + } + catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + return false; + } + } + + public async Task ExistsAsync(string stripeEventId, CancellationToken ct = default) + { + if (!db.IsConnected) return false; + + return await db.WebhookEvents + .Find(w => w.StripeEventId == stripeEventId) + .AnyAsync(ct); + } +} diff --git a/src/Nalu.Web/Endpoints/ExtractEndpoints.cs b/src/Nalu.Web/Endpoints/ExtractEndpoints.cs new file mode 100644 index 0000000..7b57079 --- /dev/null +++ b/src/Nalu.Web/Endpoints/ExtractEndpoints.cs @@ -0,0 +1,232 @@ +using Microsoft.AspNetCore.Mvc; +using Nalu.Web.Models; +using Nalu.Web.Services; +using Nalu.Web.Services.LlmRouter; + +namespace Nalu.Web.Endpoints; + +public static class ExtractEndpoints +{ + public static void MapExtractEndpoints(this WebApplication app) + { + var group = app.MapGroup("/v1/extract") + .RequireAuthorization("ApiKey") + .WithTags("Extract"); + + // ── Deterministic (1 credit) ────────────────────────────────────────── + + group.MapPost("/cpf", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_cpf", ctx, ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_cpf", req, ct)); + }) + .WithName("ExtractCpf") + .WithSummary("Extrai e valida CPF") + .WithDescription("Extrai CPF, valida dígitos verificadores (mod 11) e formata XXX.XXX.XXX-XX. Custa 1 crédito.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/cep", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_cep", ctx, ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_cep", req, ct)); + }) + .WithName("ExtractCep") + .WithSummary("Extrai CEP e retorna endereço") + .WithDescription("Extrai CEP e retorna endereço enriquecido com logradouro, bairro, cidade e estado. Custa 3 créditos.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/phone", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_phone_br", ctx, ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_phone_br", req, ct)); + }) + .WithName("ExtractPhone") + .WithSummary("Extrai telefone brasileiro com DDD") + .WithDescription("Extrai telefone com DDD e normaliza para (XX) XXXXX-XXXX ou (XX) XXXX-XXXX. Custa 1 crédito.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/email", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_email", ctx, ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_email", req, ct)); + }) + .WithName("ExtractEmail") + .WithSummary("Extrai email com correção de typos") + .WithDescription("Extrai email e corrige typos comuns em domínios (gmail, hotmail, outlook). Custa 1 crédito.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/postal-code", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_postal_code", ctx, ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_postal_code", req, ct)); + }) + .WithName("ExtractPostalCode") + .WithSummary("Extrai código postal internacional") + .WithDescription("Extrai e normaliza código postal de qualquer país exceto Brasil (use /cep para CEPs). Custa 1 crédito.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/cnpj", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_cnpj", ctx, ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_cnpj", req, ct)); + }) + .WithName("ExtractCnpj") + .WithSummary("Extrai e valida CNPJ") + .WithDescription("Extrai CNPJ, valida dígitos verificadores (mod 11) e formata XX.XXX.XXX/XXXX-XX. Custa 1 crédito.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/plate-br", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_plate_br", ctx, ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_plate_br", req, ct)); + }) + .WithName("ExtractPlateBr") + .WithSummary("Extrai placa brasileira") + .WithDescription("Suporta Mercosul (ABC1D23) e formato antigo (ABC-1234). Aceita entrada por extenso. Custa 1 crédito.") + .Produces().ProducesProblem(429).WithOpenApi(); + + // ── Light LLM (2 credits) ───────────────────────────────────────────── + + group.MapPost("/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.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/yes-no", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_yes_no", ctx, ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_yes_no", req, ct)); + }) + .WithName("ExtractYesNo") + .WithSummary("Detecta sim ou não") + .WithDescription("Retorna extracted_value='true' (sim), 'false' (não) ou null (ambíguo). Custa 2 créditos.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/birthdate", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_birthdate", ctx, ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_birthdate", req, ct)); + }) + .WithName("ExtractBirthdate") + .WithSummary("Extrai data de nascimento") + .WithDescription("Extrai data de nascimento em múltiplos formatos e idiomas, calcula idade atual. Custa 2 créditos.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/handoff", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_handoff", ctx, ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_handoff", req, ct)); + }) + .WithName("ExtractHandoff") + .WithSummary("Detecta intenção de falar com humano") + .WithDescription("Classifica wants_human (true/false), urgência (low/medium/high) e frustração. Custa 2 créditos.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/cancel-intent", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_cancel_intent", ctx, ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_cancel_intent", req, ct)); + }) + .WithName("ExtractCancelIntent") + .WithSummary("Detecta intenção de cancelamento") + .WithDescription("Diferencia cancelamento de serviço, operação atual ou frustração momentânea. Custa 2 créditos.") + .Produces().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/company-name", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_company_name", ctx, ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_company_name", req, ct)); + }) + .WithName("ExtractCompanyName") + .WithSummary("Extrai nome de empresa") + .WithDescription("Detecta sufixos legais (LTDA, ME, S/A, LLC, Inc, GmbH). Custa 2 créditos.") + .Produces().ProducesProblem(429).WithOpenApi(); + + // ── Heavy LLM (5 credits) — validate_reply ──────────────────────────── + + group.MapPost("/reply", async (HttpContext ctx, + [FromBody] ReplyRequest req, + ReplyService replyService, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_reply", ctx, ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + + try + { + var response = await replyService.AnalyzeAsync(req, ct); + return Results.Ok(response); + } + catch (ServiceUnavailableException) + { + ctx.Response.Headers["Retry-After"] = "30"; + return Results.StatusCode(503); + } + }) + .WithName("ExtractReply") + .WithSummary("Analisa contexto conversacional") + .WithDescription( + "Analisa a relação entre a mensagem do agente e a resposta do usuário. " + + "Classifica o tipo (answer, question, counter_proposal, confirmation, rejection, " + + "off_topic, greeting, handoff, cancel, unclear), extrai o significado real e sugere " + + "a próxima fala. Resolve o bug das 48 parcelas vs R$48. Custa 5 créditos.") + .Produces().ProducesProblem(429).ProducesProblem(503).WithOpenApi(); + } +} diff --git a/src/Nalu.Web/Endpoints/PlaygroundEndpoints.cs b/src/Nalu.Web/Endpoints/PlaygroundEndpoints.cs new file mode 100644 index 0000000..376a8af --- /dev/null +++ b/src/Nalu.Web/Endpoints/PlaygroundEndpoints.cs @@ -0,0 +1,58 @@ +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; + +public static class PlaygroundEndpoints +{ + private const int DailyLimit = 10; + + public static void MapPlaygroundEndpoints(this WebApplication app) + { + app.MapPost("/v1/playground/extract/{validator}", async ( + string validator, + HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, + 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) + 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(); + + try + { + var result = await pipeline.ExecuteAsync($"validate_{validator.Replace("-", "_")}", req, ct); + return Results.Ok(result); + } + catch (Exception ex) + { + return Results.Json(new { error = ex.Message }, statusCode: 503); + } + }) + .AllowAnonymous() + .WithTags("Playground") + .WithSummary("Playground — sem auth, 10 chamadas/IP/dia"); + } +} diff --git a/src/Nalu.Web/Endpoints/ValidatorsEndpoints.cs b/src/Nalu.Web/Endpoints/ValidatorsEndpoints.cs new file mode 100644 index 0000000..13f1212 --- /dev/null +++ b/src/Nalu.Web/Endpoints/ValidatorsEndpoints.cs @@ -0,0 +1,31 @@ +using Nalu.Web.Models; +using Nalu.Web.Services; + +namespace Nalu.Web.Endpoints; + +public static class ValidatorsEndpoints +{ + public static void MapValidatorsEndpoints(this WebApplication app) + { + app.MapGet("/v1/validators", (ValidatorLoader loader) => + { + var validators = loader.LoadAll().Select(v => new ValidatorInfo + { + Id = v.Id, + Endpoint = v.Endpoint, + McpTool = v.McpTool, + Description = !string.IsNullOrEmpty(v.McpDescription) + ? v.McpDescription.Split('.')[0].Trim() + : v.Description, + Version = v.Version, + Languages = v.Languages + }).ToList(); + + return Results.Ok(new { validators }); + }) + .WithName("ListValidators") + .WithSummary("Lista todos os validadores disponíveis") + .WithTags("Validators") + .WithOpenApi(); + } +} diff --git a/src/Nalu.Web/Enrichers/IEnricher.cs b/src/Nalu.Web/Enrichers/IEnricher.cs new file mode 100644 index 0000000..f248e8f --- /dev/null +++ b/src/Nalu.Web/Enrichers/IEnricher.cs @@ -0,0 +1,16 @@ +namespace Nalu.Web.Enrichers; + +public record EnrichmentResult +{ + public string? Value { get; init; } + public bool WasInvalidated { get; init; } + + public static EnrichmentResult Ok(string? value) => new() { Value = value }; + public static EnrichmentResult NotFound() => new() { WasInvalidated = true }; +} + +public interface IEnricher +{ + string Name { get; } + Task EnrichAsync(string value, CancellationToken ct = default); +} diff --git a/src/Nalu.Web/Enrichers/ViaCepEnricher.cs b/src/Nalu.Web/Enrichers/ViaCepEnricher.cs new file mode 100644 index 0000000..04ef6ca --- /dev/null +++ b/src/Nalu.Web/Enrichers/ViaCepEnricher.cs @@ -0,0 +1,157 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using MongoDB.Driver; +using Nalu.Web.Data; +using Nalu.Web.Data.Models; + +namespace Nalu.Web.Enrichers; + +public class ViaCepEnricher : IEnricher +{ + private readonly HttpClient _http; + private readonly MongoDbContext _db; + private readonly ILogger _logger; + + public string Name => "viacep"; + + public ViaCepEnricher(HttpClient http, MongoDbContext db, ILogger logger) + { + _http = http; + _db = db; + _logger = logger; + } + + public async Task EnrichAsync(string value, CancellationToken ct = default) + { + var cep = Regex.Replace(value, @"\D", ""); + if (cep.Length != 8) return EnrichmentResult.NotFound(); + + // ── 1. MongoDB cache ───────────────────────────────────────────────── + if (_db.IsConnected) + { + var cached = await _db.CepCache + .Find(c => c.Cep == cep) + .FirstOrDefaultAsync(ct); + + if (cached != null) + return BuildResult(cached.Cep, cached.Logradouro, cached.Bairro, + cached.Cidade, cached.Estado); + } + + // ── 2. ViaCEP ──────────────────────────────────────────────────────── + try + { + var json = await _http.GetFromJsonAsync( + $"https://viacep.com.br/ws/{cep}/json/", ct); + + var erroNode = json?["erro"]; + bool hasError = erroNode is not null && + (erroNode.GetValue() == "true" || + erroNode.GetValueKind() == JsonValueKind.True); + + if (!hasError && json != null) + { + var result = BuildResult(cep, + json["logradouro"]?.GetValue(), + json["bairro"]?.GetValue(), + json["localidade"]?.GetValue(), + json["uf"]?.GetValue()); + + await SaveToCacheAsync(cep, result, "viacep", ct); + return result; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Primary lookup failed for CEP {Cep}, trying fallback", cep); + } + + // ── 3. BrasilAPI fallback ──────────────────────────────────────────── + try + { + var json = await _http.GetFromJsonAsync( + $"https://brasilapi.com.br/api/cep/v2/{cep}", ct); + + if (json != null) + { + var result = BuildResult(cep, + json["street"]?.GetValue(), + json["neighborhood"]?.GetValue(), + json["city"]?.GetValue(), + json["state"]?.GetValue()); + + await SaveToCacheAsync(cep, result, "brasilapi", ct); + return result; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Fallback lookup also failed for CEP {Cep}", cep); + } + + return EnrichmentResult.NotFound(); + } + + private async Task SaveToCacheAsync(string cep, EnrichmentResult result, string source, CancellationToken ct) + { + if (!_db.IsConnected || result.Value == null) return; + + try + { + // Parse back from the JSON we just built to get individual fields + var doc = JsonSerializer.Deserialize(result.Value ?? "{}"); + if (doc == null) return; + + var entry = new CepCache + { + Cep = cep, + Logradouro = doc["logradouro"]?.GetValue() ?? "", + Bairro = doc["bairro"]?.GetValue() ?? "", + Cidade = doc["cidade"]?.GetValue() ?? "", + Estado = doc["estado"]?.GetValue() ?? "", + FormattedAddress = doc["formatted_address"]?.GetValue() ?? "", + Source = source, + CachedAt = DateTime.UtcNow, + }; + + await _db.CepCache.ReplaceOneAsync( + c => c.Cep == cep, + entry, + new ReplaceOptions { IsUpsert = true }, + ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to cache CEP {Cep} in MongoDB", cep); + } + } + + private static EnrichmentResult BuildResult(string cep, string? logradouro, string? bairro, string? cidade, string? estado) + { + logradouro ??= ""; + bairro ??= ""; + cidade ??= ""; + estado ??= ""; + + var parts = new List(); + if (!string.IsNullOrWhiteSpace(logradouro)) parts.Add(logradouro); + if (!string.IsNullOrWhiteSpace(bairro)) parts.Add(bairro); + var cityState = string.IsNullOrWhiteSpace(estado) ? cidade : $"{cidade}/{estado}"; + if (!string.IsNullOrWhiteSpace(cityState)) parts.Add(cityState); + var formattedAddress = string.Join(", ", parts); + + var address = new + { + cep = $"{cep[..5]}-{cep[5..]}", + logradouro, + bairro, + cidade, + estado, + formatted_address = formattedAddress, + }; + + return EnrichmentResult.Ok(JsonSerializer.Serialize(address)); + } +} diff --git a/src/Nalu.Web/Infrastructure/GroqClient.cs b/src/Nalu.Web/Infrastructure/GroqClient.cs new file mode 100644 index 0000000..10183be --- /dev/null +++ b/src/Nalu.Web/Infrastructure/GroqClient.cs @@ -0,0 +1,105 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Nalu.Web.Infrastructure; + +public record LlmCallResult +{ + public JsonObject? Content { get; init; } + public bool ShouldFallback { get; init; } + public string? Error { get; init; } +} + +public class GroqClient +{ + private readonly HttpClient _http; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public GroqClient(HttpClient http, IConfiguration config, ILogger logger) + { + _http = http; + _config = config; + _logger = logger; + } + + public async Task ChatAsync( + string systemPrompt, + string userMessage, + CancellationToken ct = default) + { + var model = _config["Groq:Model"] ?? "llama-3.3-70b-versatile"; + var maxTokens = _config.GetValue("Groq:MaxTokens", 500); + var temperature = _config.GetValue("Groq:Temperature", 0.1); + + var body = BuildRequestBody(model, systemPrompt, userMessage, maxTokens, temperature); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions") + { + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }; + + var response = await _http.SendAsync(request, ct); + + if (response.StatusCode == HttpStatusCode.TooManyRequests || (int)response.StatusCode >= 500) + { + _logger.LogWarning("Groq returned {Status}, triggering fallback", response.StatusCode); + return new LlmCallResult { ShouldFallback = true }; + } + + if (!response.IsSuccessStatusCode) + { + var err = await response.Content.ReadAsStringAsync(ct); + _logger.LogError("Groq error {Status}: {Body}", response.StatusCode, err); + return new LlmCallResult { Error = $"Groq {response.StatusCode}: {err}" }; + } + + return await ParseChatResponse(response, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Groq call failed unexpectedly"); + return new LlmCallResult { ShouldFallback = true }; + } + } + + internal static object BuildRequestBody( + string model, + string systemPrompt, + string userMessage, + int maxTokens, + double temperature) + { + return new + { + model, + messages = new[] + { + new { role = "system", content = systemPrompt }, + new { role = "user", content = userMessage } + }, + max_tokens = maxTokens, + temperature, + response_format = new { type = "json_object" } + }; + } + + private static async Task ParseChatResponse(HttpResponseMessage response, CancellationToken ct) + { + var json = await response.Content.ReadAsStringAsync(ct); + var doc = JsonNode.Parse(json); + var content = doc?["choices"]?[0]?["message"]?["content"]?.GetValue(); + + if (content is null) + return new LlmCallResult { Error = "Empty content in Groq response" }; + + var obj = JsonNode.Parse(content) as JsonObject; + return obj is not null + ? new LlmCallResult { Content = obj } + : new LlmCallResult { Error = "Groq returned non-object JSON" }; + } +} diff --git a/src/Nalu.Web/Infrastructure/NaluAuthHandler.cs b/src/Nalu.Web/Infrastructure/NaluAuthHandler.cs new file mode 100644 index 0000000..bf23707 --- /dev/null +++ b/src/Nalu.Web/Infrastructure/NaluAuthHandler.cs @@ -0,0 +1,82 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using Nalu.Web.Data.Repositories; + +namespace Nalu.Web.Infrastructure; + +public static class ApiKeyAuthScheme +{ + public const string Name = "ApiKey"; +} + +public record ApiKeyConfig +{ + public required string Key { get; init; } + public required string Plan { get; init; } + public required string Owner { get; init; } +} + +public class NaluAuthHandler : AuthenticationHandler +{ + private readonly ApiKeyRepository _apiKeyRepo; + private readonly List _configKeys; + + public NaluAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ApiKeyRepository apiKeyRepo, + IConfiguration config) + : base(options, logger, encoder) + { + _apiKeyRepo = apiKeyRepo; + _configKeys = config.GetSection("ApiKeys").Get>() ?? []; + } + + protected override async Task HandleAuthenticateAsync() + { + var authHeader = Request.Headers.Authorization.FirstOrDefault(); + + if (authHeader is null || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return AuthenticateResult.NoResult(); + + var key = authHeader["Bearer ".Length..].Trim(); + + string? plan; + string? owner; + + // 1. Try MongoDB first + var dbKey = await _apiKeyRepo.FindAsync(key, Context.RequestAborted); + if (dbKey is not null) + { + plan = dbKey.Plan; + owner = dbKey.Owner; + // Fire-and-forget touch — don't block request for this + _ = _apiKeyRepo.TouchAsync(key); + } + else + { + // 2. Fallback to config (test keys, bootstrap) + var cfgKey = _configKeys.FirstOrDefault(k => k.Key == key); + if (cfgKey is null) + return AuthenticateResult.Fail("Invalid API key"); + + plan = cfgKey.Plan; + owner = cfgKey.Owner; + } + + var claims = new[] + { + new Claim("api_key", key), + new Claim("plan", plan), + new Claim("owner", owner) + }; + + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name)); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return AuthenticateResult.Success(ticket); + } +} diff --git a/src/Nalu.Web/Infrastructure/OpenRouterClient.cs b/src/Nalu.Web/Infrastructure/OpenRouterClient.cs new file mode 100644 index 0000000..6b48bf9 --- /dev/null +++ b/src/Nalu.Web/Infrastructure/OpenRouterClient.cs @@ -0,0 +1,65 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Nalu.Web.Infrastructure; + +public class OpenRouterClient +{ + private readonly HttpClient _http; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public OpenRouterClient(HttpClient http, IConfiguration config, ILogger logger) + { + _http = http; + _config = config; + _logger = logger; + } + + public async Task ChatAsync( + string systemPrompt, + string userMessage, + CancellationToken ct = default) + { + var model = _config["OpenRouter:Model"] ?? "mistralai/mistral-7b-instruct"; + var maxTokens = _config.GetValue("OpenRouter:MaxTokens", 500); + var temperature = _config.GetValue("OpenRouter:Temperature", 0.1); + + var body = GroqClient.BuildRequestBody(model, systemPrompt, userMessage, maxTokens, temperature); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions") + { + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }; + + var response = await _http.SendAsync(request, ct); + + if (!response.IsSuccessStatusCode) + { + var err = await response.Content.ReadAsStringAsync(ct); + _logger.LogError("OpenRouter error {Status}: {Body}", response.StatusCode, err); + return new LlmCallResult { Error = $"OpenRouter {response.StatusCode}: {err}" }; + } + + var json = await response.Content.ReadAsStringAsync(ct); + var doc = JsonNode.Parse(json); + var content = doc?["choices"]?[0]?["message"]?["content"]?.GetValue(); + + if (content is null) + return new LlmCallResult { Error = "Empty content in OpenRouter response" }; + + var obj = JsonNode.Parse(content) as JsonObject; + return obj is not null + ? new LlmCallResult { Content = obj } + : new LlmCallResult { Error = "OpenRouter returned non-object JSON" }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "OpenRouter call failed unexpectedly"); + return new LlmCallResult { Error = ex.Message }; + } + } +} diff --git a/src/Nalu.Web/Mcp/McpServer.cs b/src/Nalu.Web/Mcp/McpServer.cs new file mode 100644 index 0000000..8ed0646 --- /dev/null +++ b/src/Nalu.Web/Mcp/McpServer.cs @@ -0,0 +1,222 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Nalu.Web.Models; +using Nalu.Web.Services; +using Nalu.Web.Services.LlmRouter; + +namespace Nalu.Web.Mcp; + +/// +/// Manual JSON-RPC 2.0 MCP server (Streamable HTTP transport). +/// Handles: initialize, initialized, tools/list, tools/call. +/// Tools are registered dynamically from Validators/*.md files. +/// +public class McpServer +{ + private readonly ValidatorLoader _loader; + private readonly IServiceScopeFactory _scopeFactory; + private readonly CreditService _credits; + private readonly ILogger _logger; + + // Shared JSON schema for standard extraction tools + private static readonly object InputSchema = new + { + type = "object", + properties = new + { + agent_input = new { type = "string", description = "Última mensagem enviada pelo agente ao usuário." }, + user_input = new { type = "string", description = "Resposta do usuário a ser analisada." }, + agent_context = new { type = "string", description = "Contexto do agente (opcional). Ex: negociador de crédito." }, + language = new { type = "string", description = "Idioma da conversa.", @default = "pt-BR" } + }, + required = new[] { "agent_input", "user_input" } + }; + + // Schema for nalu_extract_reply (different field names) + private static readonly object ReplyInputSchema = new + { + type = "object", + properties = new + { + agent_message = new { type = "string", description = "Mensagem enviada pelo agente ao usuário." }, + user_reply = new { type = "string", description = "Resposta do usuário à mensagem do agente." }, + language = new { type = "string", description = "Idioma da conversa.", @default = "pt-BR" } + }, + required = new[] { "agent_message", "user_reply" } + }; + + public McpServer( + ValidatorLoader loader, + IServiceScopeFactory scopeFactory, + CreditService credits, + ILogger logger) + { + _loader = loader; + _scopeFactory = scopeFactory; + _credits = credits; + _logger = logger; + } + + public async Task HandleAsync(HttpContext ctx, CancellationToken ct) + { + // Credit check deferred to per-tool — MCP calls are handled individually below + + // Parse JSON-RPC body + JsonDocument doc; + try + { + doc = await JsonDocument.ParseAsync(ctx.Request.Body, cancellationToken: ct); + } + catch + { + return JsonRpcError(null, -32700, "Parse error"); + } + + using (doc) + { + var root = doc.RootElement; + + var method = root.TryGetProperty("method", out var m) ? m.GetString() : null; + var hasId = root.TryGetProperty("id", out var idProp); + var id = hasId ? ReadId(idProp) : null; + + // Notifications have no id — return 202 Accepted after processing + if (method == "initialized" || (!hasId && method != null)) + { + ctx.Response.StatusCode = 202; + return Results.Empty; + } + + object? result; + try + { + result = method switch + { + "initialize" => HandleInitialize(), + "tools/list" => HandleToolsList(), + "tools/call" => await HandleToolsCallAsync(root, ct), + _ => throw new McpException(-32601, $"Method not found: {method}") + }; + } + catch (McpException ex) + { + return JsonRpcError(id, ex.Code, ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "MCP handler error for method '{Method}'", method); + return JsonRpcError(id, -32603, "Internal error"); + } + + return Results.Json(new { jsonrpc = "2.0", result, id }); + } + } + + // ── Method handlers ─────────────────────────────────────────────────────── + + private static object HandleInitialize() => new + { + protocolVersion = "2024-11-05", + capabilities = new + { + tools = new { listChanged = false } + }, + serverInfo = new { name = "nalu-ai", version = "1.0.0" } + }; + + private object HandleToolsList() + { + var tools = _loader.LoadAll() + .Where(v => !string.IsNullOrEmpty(v.McpTool)) + .Select(v => new + { + name = v.McpTool, + description = v.McpDescription, + inputSchema = v.McpTool == "nalu_extract_reply" ? ReplyInputSchema : InputSchema + }); + + return new { tools }; + } + + private async Task HandleToolsCallAsync(JsonElement root, CancellationToken ct) + { + if (!root.TryGetProperty("params", out var paramsEl)) + throw new McpException(-32602, "Missing params"); + + var toolName = paramsEl.TryGetProperty("name", out var n) ? n.GetString() : null; + if (string.IsNullOrEmpty(toolName)) + throw new McpException(-32602, "Missing params.name"); + + var validator = _loader.LoadAll().FirstOrDefault(v => v.McpTool == toolName); + if (validator is null) + throw new McpException(-32602, $"Unknown tool: {toolName}"); + + var args = paramsEl.TryGetProperty("arguments", out var a) ? a : default; + var language = GetString(args, "language") ?? "pt-BR"; + + using var scope = _scopeFactory.CreateScope(); + string json; + + if (toolName == "nalu_extract_reply") + { + // validate_reply has different input schema + var replyReq = new ReplyRequest + { + AgentMessage = GetString(args, "agent_message") ?? throw new McpException(-32602, "Missing agent_message"), + UserReply = GetString(args, "user_reply") ?? throw new McpException(-32602, "Missing user_reply"), + Language = language + }; + + var replyService = scope.ServiceProvider.GetRequiredService(); + var replyResp = await replyService.AnalyzeAsync(replyReq, ct); + json = JsonSerializer.Serialize(replyResp, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); + } + else + { + var request = new ExtractionRequest + { + AgentInput = GetString(args, "agent_input") ?? throw new McpException(-32602, "Missing agent_input"), + UserInput = GetString(args, "user_input") ?? throw new McpException(-32602, "Missing user_input"), + AgentContext = GetString(args, "agent_context"), + Language = language + }; + + var pipeline = scope.ServiceProvider.GetRequiredService(); + var response = await pipeline.ExecuteAsync(validator.Id, request, ct); + json = JsonSerializer.Serialize(response, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); + } + + return new + { + content = new[] { new { type = "text", text = json } }, + isError = false + }; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static string? GetString(JsonElement el, string key) => + el.ValueKind == JsonValueKind.Object && el.TryGetProperty(key, out var v) + ? v.GetString() + : null; + + private static object? ReadId(JsonElement idProp) => idProp.ValueKind switch + { + JsonValueKind.Number => (object)idProp.GetInt64(), + JsonValueKind.String => idProp.GetString(), + _ => null + }; + + private static IResult JsonRpcError(object? id, int code, string message) => + Results.Json(new + { + jsonrpc = "2.0", + error = new { code, message }, + id + }); + + private sealed class McpException(int code, string message) : Exception(message) + { + public int Code { get; } = code; + } +} diff --git a/src/Nalu.Web/Models/ExtractionRequest.cs b/src/Nalu.Web/Models/ExtractionRequest.cs new file mode 100644 index 0000000..8f3667c --- /dev/null +++ b/src/Nalu.Web/Models/ExtractionRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Nalu.Web.Models; + +public record ExtractionRequest +{ + [JsonPropertyName("agent_input")] + public required string AgentInput { get; init; } + + [JsonPropertyName("user_input")] + public required string UserInput { get; init; } + + [JsonPropertyName("agent_context")] + public string? AgentContext { get; init; } + + [JsonPropertyName("language")] + public string Language { get; init; } = "pt-BR"; +} diff --git a/src/Nalu.Web/Models/ExtractionResponse.cs b/src/Nalu.Web/Models/ExtractionResponse.cs new file mode 100644 index 0000000..53ce6f2 --- /dev/null +++ b/src/Nalu.Web/Models/ExtractionResponse.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace Nalu.Web.Models; + +public record ExtractionResponse +{ + [JsonPropertyName("obtained")] + public bool Obtained { get; init; } + + [JsonPropertyName("extracted_value")] + public string? ExtractedValue { get; init; } + + /// "high" | "medium" | "low" + [JsonPropertyName("confidence")] + public string Confidence { get; init; } = "low"; + + [JsonPropertyName("certain")] + public bool Certain { get; init; } + + [JsonPropertyName("suggestion_to_agent")] + public string? SuggestionToAgent { get; init; } + + [JsonPropertyName("has_suggestion")] + public bool HasSuggestion => SuggestionToAgent is not null; + + /// "scalar" = plain string value | "object" = JSON-as-string, parse before use | null = no value (obtained: false) + [JsonPropertyName("value_format")] + public string? ValueFormat { get; init; } +} diff --git a/src/Nalu.Web/Models/ReplyRequest.cs b/src/Nalu.Web/Models/ReplyRequest.cs new file mode 100644 index 0000000..bc108e8 --- /dev/null +++ b/src/Nalu.Web/Models/ReplyRequest.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Nalu.Web.Models; + +public record ReplyRequest +{ + [JsonPropertyName("agent_message")] + public required string AgentMessage { get; init; } + + [JsonPropertyName("user_reply")] + public required string UserReply { get; init; } + + [JsonPropertyName("language")] + public string Language { get; init; } = "pt-BR"; +} diff --git a/src/Nalu.Web/Models/ReplyResponse.cs b/src/Nalu.Web/Models/ReplyResponse.cs new file mode 100644 index 0000000..e3d096a --- /dev/null +++ b/src/Nalu.Web/Models/ReplyResponse.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Serialization; + +namespace Nalu.Web.Models; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ReplyType +{ + Answer, + Question, + CounterProposal, + Confirmation, + Rejection, + OffTopic, + Greeting, + Handoff, + Cancel, + Unclear +} + +public record ReplyResponse +{ + [JsonPropertyName("obtained")] + public bool Obtained { get; init; } + + [JsonPropertyName("reply_type")] + public ReplyType? ReplyType { get; init; } + + [JsonPropertyName("extracted_value")] + public string? ExtractedValue { get; init; } + + /// "quantity" | "amount" | "date" | "text" | "boolean" | null + [JsonPropertyName("value_type")] + public string? ValueType { get; init; } + + [JsonPropertyName("extracted_meaning")] + public string? ExtractedMeaning { get; init; } + + /// 0.0 – 1.0 float (validate_reply exposes raw confidence — it's a premium endpoint) + [JsonPropertyName("confidence")] + public double Confidence { get; init; } + + [JsonPropertyName("needs_clarification")] + public bool NeedsClarification { get; init; } + + [JsonPropertyName("suggestion_to_agent")] + public string? SuggestionToAgent { get; init; } + + [JsonPropertyName("has_suggestion")] + public bool HasSuggestion => SuggestionToAgent is not null; +} diff --git a/src/Nalu.Web/Models/ValidatorDefinition.cs b/src/Nalu.Web/Models/ValidatorDefinition.cs new file mode 100644 index 0000000..b3ee4ff --- /dev/null +++ b/src/Nalu.Web/Models/ValidatorDefinition.cs @@ -0,0 +1,41 @@ +namespace Nalu.Web.Models; + +public class ValidatorDefinition +{ + public required string Id { get; init; } + public string Type { get; set; } = "extraction"; + public string Version { get; set; } = "1.0"; + public List Languages { get; set; } = []; + public string Endpoint { get; set; } = ""; + public string McpTool { get; set; } = ""; + public string McpDescription { get; set; } = ""; + public string Description { get; set; } = ""; + + // Deterministic rules + public HashSet StopWords { get; set; } = []; + public List RejectPatterns { get; set; } = []; + public List AcceptPatterns { get; set; } = []; + public Dictionary Constraints { get; set; } = []; + + // LLM + public string Prompt { get; set; } = ""; + public List FewShotExamples { get; set; } = []; + + // Processing + public List PostProcessors { get; set; } = []; + public List Enrichers { 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); + + // Localized stop words (keyed by locale) + public Dictionary> LocalizedStopWords { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +public record FewShotExample +{ + public required string AgentInput { get; init; } + public required string UserInput { get; init; } + public required string Output { get; init; } +} diff --git a/src/Nalu.Web/Models/ValidatorInfo.cs b/src/Nalu.Web/Models/ValidatorInfo.cs new file mode 100644 index 0000000..e4b5f74 --- /dev/null +++ b/src/Nalu.Web/Models/ValidatorInfo.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Nalu.Web.Models; + +public record ValidatorInfo +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("endpoint")] + public required string Endpoint { get; init; } + + [JsonPropertyName("mcp_tool")] + public required string McpTool { get; init; } + + [JsonPropertyName("description")] + public required string Description { get; init; } + + [JsonPropertyName("version")] + public required string Version { get; init; } + + [JsonPropertyName("languages")] + public required List Languages { get; init; } +} diff --git a/src/Nalu.Web/Nalu.Web.csproj b/src/Nalu.Web/Nalu.Web.csproj new file mode 100644 index 0000000..b0e76b7 --- /dev/null +++ b/src/Nalu.Web/Nalu.Web.csproj @@ -0,0 +1,25 @@ + + + net9.0 + enable + enable + Nalu.Web + Nalu.Web + + + + + Always + + + + + + + + + + + + + diff --git a/src/Nalu.Web/Pages/Auth/Callback.cshtml b/src/Nalu.Web/Pages/Auth/Callback.cshtml new file mode 100644 index 0000000..b244d1e --- /dev/null +++ b/src/Nalu.Web/Pages/Auth/Callback.cshtml @@ -0,0 +1,2 @@ +@page "/auth/callback" +@model Nalu.Web.Pages.Auth.CallbackModel diff --git a/src/Nalu.Web/Pages/Auth/Callback.cshtml.cs b/src/Nalu.Web/Pages/Auth/Callback.cshtml.cs new file mode 100644 index 0000000..6bda213 --- /dev/null +++ b/src/Nalu.Web/Pages/Auth/Callback.cshtml.cs @@ -0,0 +1,76 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Nalu.Web.Services; + +namespace Nalu.Web.Pages.Auth; + +public class CallbackModel(UserService userService, ILogger logger) : PageModel +{ + public async Task OnGetAsync(string? returnUrl = null) + { + var result = await HttpContext.AuthenticateAsync("ExternalCookie"); + if (!result.Succeeded) + { + logger.LogWarning("External auth failed: {Error}", result.Failure?.Message); + return RedirectToPage("/Login"); + } + + var principal = result.Principal!; + var provider = result.Properties?.Items[".AuthScheme"] ?? "unknown"; + + var providerId = principal.FindFirstValue(ClaimTypes.NameIdentifier) + ?? principal.FindFirstValue("sub") + ?? throw new InvalidOperationException("No provider ID in claims"); + + var email = principal.FindFirstValue(ClaimTypes.Email) + ?? principal.FindFirstValue("email") + ?? $"{providerId}@{provider}.oauth"; + + var name = principal.FindFirstValue(ClaimTypes.Name) + ?? principal.FindFirstValue("name"); + + var picture = principal.FindFirstValue("picture") + ?? principal.FindFirstValue("urn:github:avatar"); + + var loginResult = await userService.LoginOrCreateAsync( + provider.ToLowerInvariant(), providerId, email, name, picture); + + // Issue site cookie + var claims = new List + { + new(ClaimTypes.NameIdentifier, loginResult.User.Id), + new(ClaimTypes.Email, email), + new(ClaimTypes.Name, name ?? email), + new("plan", loginResult.User.Plan), + new("picture", picture ?? ""), + }; + + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var cookiePrincipal = new ClaimsPrincipal(identity); + + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + cookiePrincipal, + new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.AddDays(30), + }); + + // Delete external cookie + await HttpContext.SignOutAsync("ExternalCookie"); + + // Show API key once for new users + if (loginResult.IsNew) + TempData["NewApiKey"] = loginResult.ApiKey.Key; + + var safeReturn = IsLocalUrl(returnUrl) ? returnUrl : "/painel"; + return Redirect(safeReturn!); + } + + private bool IsLocalUrl(string? url) => + !string.IsNullOrEmpty(url) && url.StartsWith('/') && !url.StartsWith("//"); +} diff --git a/src/Nalu.Web/Pages/Auth/Logout.cshtml b/src/Nalu.Web/Pages/Auth/Logout.cshtml new file mode 100644 index 0000000..7a65414 --- /dev/null +++ b/src/Nalu.Web/Pages/Auth/Logout.cshtml @@ -0,0 +1,2 @@ +@page "/auth/logout" +@model Nalu.Web.Pages.Auth.LogoutModel diff --git a/src/Nalu.Web/Pages/Auth/Logout.cshtml.cs b/src/Nalu.Web/Pages/Auth/Logout.cshtml.cs new file mode 100644 index 0000000..0199156 --- /dev/null +++ b/src/Nalu.Web/Pages/Auth/Logout.cshtml.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Auth; + +public class LogoutModel : PageModel +{ + public async Task OnGetAsync() + { + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Redirect("/"); + } +} diff --git a/src/Nalu.Web/Pages/Casos/Parcelas48x.cshtml b/src/Nalu.Web/Pages/Casos/Parcelas48x.cshtml new file mode 100644 index 0000000..e930ce7 --- /dev/null +++ b/src/Nalu.Web/Pages/Casos/Parcelas48x.cshtml @@ -0,0 +1,168 @@ +@page "/casos/parcelas-48x" +@model Nalu.Web.Pages.Casos.Parcelas48xModel +@{ + ViewData["Title"] = "O bug das 48 parcelas: quando seu chatbot confunde quantidade com valor"; + ViewData["Description"] = "Agente oferece 20x de R$100. Cliente diz 'Bora em 48?'. Bot registra R$48. Acontece todo dia. Saiba como validate_reply resolve por R$ 0,0097."; +} + + +
+
+ +
Caso de uso · validate_reply
+

+ "O bug das 48 parcelas": quando seu chatbot confunde quantidade com valor +

+

+ Acontece todo dia em chatbots de cobrança, vendas e atendimento. E custa vendas. +

+
+
+ + +
+
+

+ Sua empresa tem um agente de IA para vendas. Ele oferece parcelamento. + O cliente responde com um número. O bot extrai o número. Erro: o bot confundiu + a quantidade de parcelas que o cliente propôs com um valor em reais. +

+

+ Resultado: proposta errada, cliente frustrado, venda perdida. + E o pior: o bug é silencioso — o bot não sabe que errou. +

+
+
+ + +
+
+

O bug em ação

+
+
BOT TRADICIONAL — DIÁLOGO REAL
+
+
+ Agente: + "Posso parcelar em 20x de R$100. Topa?" +
+
+ Usuário: + "Bora em 48?" +
+
+
❌ Bot extraiu: "48"
+
Interpretou como: R$ 48,00
+
Correto seria: 48 parcelas (contraproposta)
+
+
+
+
+
+ + +
+
+

Por que o bot erra sozinho

+

+ O bot recebe apenas a mensagem do usuário: "Bora em 48?". + Sem contexto de que o agente acabou de oferecer PARCELAS, "48" parece um valor monetário + ou simplesmente um número sem significado definido. +

+

+ Mesmo com um prompt bem elaborado, o bot não foi projetado para analisar + o par agente+usuário como uma unidade semântica — ele vê só + a resposta isolada. +

+
+
+ + +
+
+

Como o NALU AI resolve com validate_reply

+
+
API RESPONSE — validate_reply
+
{
+  "obtained": true,
+  "reply_type": "counter_proposal",
+  "extracted_value": "48",
+  "value_type": "quantity",
+  "extracted_meaning": "Usuário propôs 48 parcelas como
+    contraproposta à oferta de 20 parcelas de R$100",
+  "confidence": 0.95,
+  "needs_clarification": false,
+  "suggestion_to_agent": "O cliente está propondo 48 parcelas
+    em vez das 20 oferecidas. Você pode ajustar a proposta
+    para 48x de um valor menor?"
+}
+
+

+ O validate_reply analisa o PAR agente+usuário e entende que "48" é uma + contraproposta de quantidade de parcelas, não um valor monetário — porque o agente + acabou de oferecer PARCELAS (20x de R$100), não um preço fixo. +

+
+
+ + +
+
+

Custo de resolver

+
+
+
Custo por análise
+
R$ 0,0097
+
5 créditos · plano Starter
+
+
+
Custo de NÃO resolver
+
Venda perdida
+
Cliente frustrado · dados poluídos
+
+
+
+
+ + +
+
+

Código de integração

+
+
cURL
+
curl https://api.naluai.com/v1/extract/reply \
+  -H "Authorization: Bearer SEU_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "agent_message": "Posso parcelar em 20x de R$100. Topa?",
+    "user_reply": "Bora em 48?",
+    "language": "pt-BR"
+  }'
+ +
JavaScript (n8n / Make)
+
const { reply_type, extracted_value, value_type } =
+  await $http.post('https://api.naluai.com/v1/extract/reply', {
+    agent_message: $node['Agent'].json.message,
+    user_reply: $node['User'].json.reply,
+    language: 'pt-BR'
+  }, { headers: { Authorization: 'Bearer ' + $env.NALU_TOKEN } });
+
+// reply_type === 'counter_proposal'
+// extracted_value === '48'
+// value_type === 'quantity'
+
+
+
+ + +
+
+

Teste com seus próprios diálogos

+

O playground é grátis. 10 chamadas por dia, sem cadastro.

+ + Testar no playground → + +

Ou criar uma conta e ganhar 3.000 créditos grátis.

+
+
diff --git a/src/Nalu.Web/Pages/Casos/Parcelas48x.cshtml.cs b/src/Nalu.Web/Pages/Casos/Parcelas48x.cshtml.cs new file mode 100644 index 0000000..5cba88f --- /dev/null +++ b/src/Nalu.Web/Pages/Casos/Parcelas48x.cshtml.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Casos; + +public class Parcelas48xModel : PageModel +{ + public void OnGet() { } +} diff --git a/src/Nalu.Web/Pages/Checkout.cshtml b/src/Nalu.Web/Pages/Checkout.cshtml new file mode 100644 index 0000000..6193920 --- /dev/null +++ b/src/Nalu.Web/Pages/Checkout.cshtml @@ -0,0 +1,21 @@ +@page "/checkout" +@model Nalu.Web.Pages.CheckoutModel +@{ + Layout = null; + var plan = Request.Query["plan"].ToString(); +} + + +Redirecionando para pagamento… + +
+
+

Redirecionando para o pagamento…

+
+ +
+ + +
+ + diff --git a/src/Nalu.Web/Pages/Checkout.cshtml.cs b/src/Nalu.Web/Pages/Checkout.cshtml.cs new file mode 100644 index 0000000..112df6c --- /dev/null +++ b/src/Nalu.Web/Pages/Checkout.cshtml.cs @@ -0,0 +1,96 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Stripe.Checkout; + +namespace Nalu.Web.Pages; + +public class CheckoutModel(IConfiguration config, ILogger logger) : PageModel +{ + private static readonly Dictionary PlanMeta = new() + { + ["starter"] = ("NALU AI — Starter", "15.000 créditos/mês"), + ["indie"] = ("NALU AI — Indie", "50.000 créditos/mês"), + ["pro"] = ("NALU AI — Pro", "250.000 créditos/mês"), + }; + + public IActionResult OnGet(string plan) + { + if (!PlanMeta.ContainsKey(plan)) + return RedirectToPage("/Precos"); + + if (!(User.Identity?.IsAuthenticated ?? false)) + return Redirect($"/login?returnUrl={Uri.EscapeDataString($"/checkout?plan={plan}")}"); + + return Page(); + } + + public async Task OnPostAsync(string plan) + { + if (!PlanMeta.ContainsKey(plan)) + return RedirectToPage("/Precos"); + + if (!(User.Identity?.IsAuthenticated ?? false)) + return Redirect($"/login?returnUrl={Uri.EscapeDataString($"/checkout?plan={plan}")}"); + + var priceBrl = config.GetValue($"Plans:{plan}:price_brl"); + if (priceBrl <= 0) + { + TempData["CheckoutError"] = "Plano inválido ou sem preço configurado."; + return RedirectToPage("/Precos"); + } + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var email = User.FindFirstValue(ClaimTypes.Email) ?? ""; + var baseUrl = $"{Request.Scheme}://{Request.Host}"; + var meta = PlanMeta[plan]; + + var options = new SessionCreateOptions + { + Mode = "subscription", + CustomerEmail = email, + LineItems = + [ + new SessionLineItemOptions + { + Quantity = 1, + PriceData = new SessionLineItemPriceDataOptions + { + Currency = "brl", + UnitAmount = (long)priceBrl, // já em centavos no config (2900 = R$29) + Recurring = new SessionLineItemPriceDataRecurringOptions + { + Interval = "month", + }, + ProductData = new SessionLineItemPriceDataProductDataOptions + { + Name = meta.Name, + Description = meta.Description, + }, + }, + } + ], + Metadata = new Dictionary + { + ["user_id"] = userId, + ["plan"] = plan, + }, + SuccessUrl = $"{baseUrl}/precos/sucesso?session_id={{CHECKOUT_SESSION_ID}}", + CancelUrl = $"{baseUrl}/precos", + AllowPromotionCodes = true, + }; + + try + { + var service = new SessionService(); + var session = await service.CreateAsync(options); + return Redirect(session.Url); + } + catch (Stripe.StripeException ex) + { + logger.LogError(ex, "Stripe checkout error for plan {Plan}", plan); + TempData["CheckoutError"] = "Erro ao iniciar pagamento. Tente novamente."; + return RedirectToPage("/Precos"); + } + } +} diff --git a/src/Nalu.Web/Pages/Docs/ApiReference.cshtml b/src/Nalu.Web/Pages/Docs/ApiReference.cshtml new file mode 100644 index 0000000..e354166 --- /dev/null +++ b/src/Nalu.Web/Pages/Docs/ApiReference.cshtml @@ -0,0 +1,174 @@ +@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."; +} + +
+
+
+ Docs / API Reference +
+

API Reference

+

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

+
+
+ +
+
+ + +
+
Autenticação
+

Todas as rotas requerem Authorization: Bearer SUA_API_KEY no header.

+
+ + +
+

Corpo da requisição (ExtractionRequest)

+ + + + + + + + + + + + + + + +
CampoTipoObrigatórioDescrição
user_inputstringsimO que o usuário digitou
agent_inputstring?nãoPergunta do agente (melhora SmartSuggestion)
agent_contextstring?nãoObjetivo geral do agente (melhora sugestões contextuais)
languagestring?nãopt-BR (padrão), en-US, es-ES
+
+ + +
+

Endpoints

+
+ + + + + + + + + + @foreach (var ep in new[] { + ("/v1/extract/cpf", "Extrai CPF, valida mod 11, formata XXX.XXX.XXX-XX", 3), + ("/v1/extract/cep", "Extrai CEP e retorna endereço enriquecido (logradouro, bairro, cidade, estado)", 3), + ("/v1/extract/cnpj", "Extrai CNPJ, valida mod 11, formata XX.XXX.XXX/XXXX-XX", 3), + ("/v1/extract/email", "Extrai email, corrige typos de domínio", 3), + ("/v1/extract/phone", "Extrai telefone com DDD, normaliza formato brasileiro", 3), + ("/v1/extract/plate-br", "Extrai placa Mercosul ou formato antigo, aceita por extenso", 3), + ("/v1/extract/postal-code", "Código postal internacional (não CEP)", 3), + ("/v1/extract/name", "Extrai nome completo, ignora saudações e títulos", 3), + ("/v1/extract/yes-no", "Detecta sim/não em linguagem natural", 3), + ("/v1/extract/birthdate", "Extrai data de nascimento, calcula idade", 3), + ("/v1/extract/handoff", "Detecta intenção de falar com humano + urgência", 3), + ("/v1/extract/cancel-intent","Classifica cancelamento de serviço vs operação atual", 3), + ("/v1/extract/company-name", "Extrai nome de empresa, detecta sufixos legais", 3), + ("/v1/extract/reply", "Analisa contexto conversacional completo (par agente+usuário)", 5), + }) + { + + + + + + } + +
POSTDescriçãoCréditos
@ep.Item1@ep.Item2@ep.Item3
+
+
+ + +
+

validate_reply — corpo especial

+

Este endpoint usa ReplyRequest em vez de ExtractionRequest:

+ + + + + + + + + + + + + + +
CampoTipoDescrição
agent_messagestringO que o agente disse
user_replystringO que o usuário respondeu
agent_contextstring?Contexto geral do agente
languagestring?pt-BR (padrão)
+
+
# Exemplo: detectar contraproposta de parcelas
+curl https://api.naluai.com/v1/extract/reply \
+  -H "Authorization: Bearer SUA_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "agent_message": "Posso parcelar em 20x de R$100. Topa?",
+    "user_reply":    "Bora em 48?",
+    "agent_context": "Agente de negociação de parcelas"
+  }'
+
+# Resposta:
+# {
+#   "reply_type":       "counter_proposal",
+#   "extracted_value":  "48",
+#   "value_type":       "quantity",
+#   "extracted_meaning":"48 parcelas, não R$48",
+#   "confidence":       0.95,
+#   "suggestion_to_agent": "Cliente propõe 48 parcelas..."
+# }
+
+
+ + +
+

Códigos de resposta

+ + + + + + + + + + + + + +
HTTPSignificado
200Sucesso — ver campo obtained para saber se extraiu
401API Key inválida ou ausente
429Créditos esgotados ou rate limit atingido
503Motor de IA indisponível — tentar novamente em 30s
+

Ver Erros para payloads completos.

+
+ + +
+

Headers de resposta

+ + + + + + + + + + + + + +
HeaderDescrição
X-Credits-UsedCréditos consumidos nesta chamada
X-Credits-RemainingCréditos restantes no mês
X-Credits-LimitLimite mensal do seu plano
X-Credits-ResetData de reset (ISO 8601)
+
+ + +
+
diff --git a/src/Nalu.Web/Pages/Docs/ApiReference.cshtml.cs b/src/Nalu.Web/Pages/Docs/ApiReference.cshtml.cs new file mode 100644 index 0000000..14b067c --- /dev/null +++ b/src/Nalu.Web/Pages/Docs/ApiReference.cshtml.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Docs; + +public class ApiReferenceModel : PageModel +{ + public void OnGet() { } +} diff --git a/src/Nalu.Web/Pages/Docs/Creditos.cshtml b/src/Nalu.Web/Pages/Docs/Creditos.cshtml new file mode 100644 index 0000000..c878046 --- /dev/null +++ b/src/Nalu.Web/Pages/Docs/Creditos.cshtml @@ -0,0 +1,140 @@ +@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."; +} + +
+
+
+ Docs / Créditos +
+

Créditos

+

Como funciona o sistema de créditos e limites de plano.

+
+
+ +
+
+ + +
+

Como funciona

+

+ Cada chamada à API consome créditos. Créditos são debitados no momento da chamada. + O limite é mensal e reseta no primeiro dia de cada mês às 00:00 UTC. +

+
+
+
3
+
créditos — validadores padrão
+
+
+
5
+
créditos — validate_reply
+
+
+
0
+
créditos — cache hit
+
+
+
+ + +
+

Custo por validador

+
+ + + + + + + + + + + + + + + + + + + + + + + +
ValidadoresCréditosStarter (R$29)Pro (R$199)
CPF, CEP, CNPJ, email, telefone, placa, postal-code, nome, sim/não, data nasc., handoff, cancelamento, empresa3R$ 0,0058R$ 0,0024
validate_reply (análise de contexto aprofundada)5R$ 0,0097R$ 0,0040
+
+
+ + +
+

Limites por plano

+
+ + + + + + + + + + + + + + + +
PlanoCréditos/mêsLimite diárioPreço
Free3.000100 cr/diaR$ 0
Starter15.000sem limiteR$ 29/mês
Indie50.000sem limiteR$ 69/mês
Pro250.000sem limiteR$ 199/mês
+
+
+ + +
+

Rate limits (todos os planos)

+
+
+ Por IP por minuto + 60 req/min +
+
+ Por IP por hora + 500 req/hora +
+
+

Rate limit retorna HTTP 429. Ver Erros.

+
+ + +
+

Monitorar créditos

+

Cada resposta inclui headers com o estado atual:

+
+
X-Credits-Used:      3        # consumido nesta chamada
+X-Credits-Remaining: 2997     # restantes no mês
+X-Credits-Limit:     3000     # limite do plano
+X-Credits-Reset:     2026-06-01T00:00:00Z
+
+
+ + +
+

Cache

+

+ Requisições idênticas (mesmo agent_input + user_input + agent_context + language) + retornam o resultado em cache por 60 minutos sem consumir créditos adicionais. +

+
+ + +
+
diff --git a/src/Nalu.Web/Pages/Docs/Creditos.cshtml.cs b/src/Nalu.Web/Pages/Docs/Creditos.cshtml.cs new file mode 100644 index 0000000..1c32621 --- /dev/null +++ b/src/Nalu.Web/Pages/Docs/Creditos.cshtml.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Docs; + +public class CreditosModel : PageModel +{ + public void OnGet() { } +} diff --git a/src/Nalu.Web/Pages/Docs/Erros.cshtml b/src/Nalu.Web/Pages/Docs/Erros.cshtml new file mode 100644 index 0000000..a7ff58d --- /dev/null +++ b/src/Nalu.Web/Pages/Docs/Erros.cshtml @@ -0,0 +1,158 @@ +@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."; +} + +
+
+
+ Docs / Erros +
+

Erros

+

Todos os erros retornam JSON com campo error.

+
+
+ +
+
+ + +
+
+ 401 +

Unauthorized

+
+

API Key inválida, expirada ou ausente no header.

+
+
{
+  "error": "unauthorized",
+  "message": "API Key inválida ou ausente."
+}
+
+

Verifique o header: Authorization: Bearer SUA_API_KEY

+
+ + +
+
+ 429 +

Rate Limit (IP)

+
+

Mais de 60 req/min ou 500 req/hora do mesmo IP.

+
+
{
+  "error": "rate_limit_exceeded",
+  "message": "Muitas requisições por minuto deste IP. Aguarde alguns segundos.",
+  "retry_after_seconds": 60
+}
+
+

Aguardar retry_after_seconds antes de tentar novamente.

+
+ + +
+
+ 429 +

Limite Diário (Free)

+
+

Plano Free atingiu 100 créditos por dia.

+
+
{
+  "error": "daily_limit_exceeded",
+  "message": "Limite diário de 100 créditos atingido no plano gratuito. Renova amanhã ou faça upgrade.",
+  "credits_used": 100,
+  "credits_limit": 100,
+  "reset_at": "2026-05-11T00:00:00Z",
+  "upgrade_url": "https://naluai.com/precos",
+  "hint": "Plano Starter: 15.000 créditos/mês sem limite diário. R$ 29/mês."
+}
+
+
+ + +
+
+ 429 +

Créditos Esgotados

+
+

Limite mensal do plano atingido.

+
+
{
+  "error": "credits_exhausted",
+  "message": "Seus créditos do mês acabaram. Seu plano (Free) permite 3.000 créditos/mês.",
+  "credits_used": 3000,
+  "credits_limit": 3000,
+  "reset_at": "2026-06-01T00:00:00Z",
+  "upgrade_url": "https://naluai.com/precos",
+  "hint": "Upgrade para Starter por apenas R$ 0,0058 por validação."
+}
+
+
+ + +
+
+ 503 +

Service Unavailable

+
+

O motor de IA está temporariamente indisponível. O header Retry-After: 30 indica quando tentar.

+
+
# Headers na resposta:
+Retry-After: 30
+
+# Estratégia recomendada:
+- Tentar novamente após 30 segundos
+- Máximo 3 tentativas com backoff exponencial
+- Após 3 falhas: retornar erro para o usuário
+
+
+ + +
+
+ 200 +

obtained: false (não é erro)

+
+

+ HTTP 200 com obtained: false significa que o dado não foi encontrado na resposta do usuário. + Não é um erro — use suggestion_to_agent para perguntar novamente. +

+
+
{
+  "obtained": false,
+  "extracted_value": null,
+  "certain": false,
+  "confidence": "low",
+  "suggestion_to_agent": "Desculpe, não consegui identificar seu CPF. Pode digitar apenas os números? Ex: 111.444.777-35"
+}
+
+
+ + +
+

Estratégia de retry recomendada

+ + + + + + + + + + + + + + +
CódigoRetry?Como
401NãoCorrigir API Key
429 rateSimAguardar retry_after_seconds
429 creditsNãoFazer upgrade ou aguardar reset mensal
503SimBackoff: 30s, 60s, 120s
+
+ + +
+
diff --git a/src/Nalu.Web/Pages/Docs/Erros.cshtml.cs b/src/Nalu.Web/Pages/Docs/Erros.cshtml.cs new file mode 100644 index 0000000..0d79087 --- /dev/null +++ b/src/Nalu.Web/Pages/Docs/Erros.cshtml.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Docs; + +public class ErrosModel : PageModel +{ + public void OnGet() { } +} diff --git a/src/Nalu.Web/Pages/Docs/Fluxos.cshtml b/src/Nalu.Web/Pages/Docs/Fluxos.cshtml new file mode 100644 index 0000000..a596a47 --- /dev/null +++ b/src/Nalu.Web/Pages/Docs/Fluxos.cshtml @@ -0,0 +1,145 @@ +@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."; +} + +
+
+
+ Docs / Fluxos +
+

Fluxos de integração

+

Padrões recomendados para encadear validadores em conversas reais.

+
+
+ +
+
+ + +
+

Fluxo 1 — Coleta de dados cadastrais

+

Padrão para chatbots de cadastro: nome → CPF → telefone → email.

+
+
1. POST /v1/extract/name
+   → obtained: true  → salvar, seguir para CPF
+   → obtained: false → usar suggestion_to_agent, perguntar de novo
+
+2. POST /v1/extract/cpf
+   → obtained: true, certain: true  → salvar, seguir para telefone
+   → obtained: true, certain: false → confirmar com usuário
+   → obtained: false → usar suggestion_to_agent
+
+3. POST /v1/extract/phone
+   → obtained: true → salvar, seguir para email
+   → obtained: false → suggestion_to_agent
+
+4. POST /v1/extract/email
+   → obtained: true → cadastro completo ✓
+   → obtained: false → suggestion_to_agent
+
+Custo total por cadastro: 12 créditos (4 × 3 cr.)
+
+
+ + +
+

Fluxo 2 — Negociação de parcelas

+

Resolve o "bug das 48 parcelas" usando validate_reply para interpretar contrapropostas.

+
+
1. Agente oferece: "Posso parcelar em 20x de R$100. Topa?"
+
+2. POST /v1/extract/reply
+   { agent_message: "...", user_reply: "Bora em 48?" }
+
+   → reply_type: "counter_proposal"
+     → extracted_value: "48", value_type: "quantity"
+     → agente avalia se pode oferecer 48x
+
+   → reply_type: "confirmation"
+     → prosseguir com fechamento
+
+   → reply_type: "rejection"
+     → oferecer alternativa
+
+   → reply_type: "handoff"
+     → POST /v1/extract/handoff para confirmar urgência
+     → transferir para humano
+
+   → reply_type: "cancel"
+     → POST /v1/extract/cancel-intent para classificar
+     → acionar fluxo de retenção
+
+Custo: 5 créditos por análise de resposta
+
+
+ + +
+

Fluxo 3 — n8n / Make / Zapier

+

Use um nó HTTP Request apontando para o endpoint desejado.

+
+
# n8n — nó "HTTP Request"
+Method:  POST
+URL:     https://api.naluai.com/v1/extract/cpf
+Headers:
+  Authorization: Bearer {{ $env.NALU_API_KEY }}
+  Content-Type:  application/json
+Body (JSON):
+{
+  "agent_input": "{{ $json.agent_question }}",
+  "user_input":  "{{ $json.user_message }}",
+  "language":    "pt-BR"
+}
+
+# Próximo nó: IF
+# Condição: {{ $json.obtained }} === true
+#   → ramo OK: salvar extracted_value
+#   → ramo NOK: enviar suggestion_to_agent ao usuário
+
+
+ + +
+

Fluxo 4 — Padrão de re-pergunta

+

Como usar suggestion_to_agent corretamente.

+
+
MAX_RETRIES = 3
+retries = 0
+
+loop:
+  response = POST /v1/extract/{validator}
+             { agent_input, user_input: coleta_atual, agent_context }
+
+  if response.obtained:
+    → usar extracted_value, sair do loop
+
+  retries++
+  if retries >= MAX_RETRIES:
+    → escalar para humano ou abandonar coleta
+
+  # Usar sugestão do NALU como próxima mensagem do agente
+  proxima_mensagem = response.suggestion_to_agent
+  coleta_atual = aguardar_resposta_usuario(proxima_mensagem)
+
+
+ + +
+
Boas práticas
+
    +
  • • Sempre envie agent_context — melhora muito as sugestões contextuais
  • +
  • • Limite 3 tentativas por campo antes de escalar para humano
  • +
  • • Use certain: false para pedir confirmação, não para bloquear o fluxo
  • +
  • • validate_reply pode retornar handoff ou cancel — sempre trate esses casos
  • +
+
+ + +
+
diff --git a/src/Nalu.Web/Pages/Docs/Fluxos.cshtml.cs b/src/Nalu.Web/Pages/Docs/Fluxos.cshtml.cs new file mode 100644 index 0000000..3183c63 --- /dev/null +++ b/src/Nalu.Web/Pages/Docs/Fluxos.cshtml.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Docs; + +public class FluxosModel : PageModel +{ + public void OnGet() { } +} diff --git a/src/Nalu.Web/Pages/Docs/Index.cshtml b/src/Nalu.Web/Pages/Docs/Index.cshtml new file mode 100644 index 0000000..6fd5c48 --- /dev/null +++ b/src/Nalu.Web/Pages/Docs/Index.cshtml @@ -0,0 +1,83 @@ +@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."; +} + +
+
+

Documentação

+

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

+
+
+ +
+ +
diff --git a/src/Nalu.Web/Pages/Docs/Index.cshtml.cs b/src/Nalu.Web/Pages/Docs/Index.cshtml.cs new file mode 100644 index 0000000..4924779 --- /dev/null +++ b/src/Nalu.Web/Pages/Docs/Index.cshtml.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Docs; + +public class IndexModel : PageModel +{ + public void OnGet() { } +} diff --git a/src/Nalu.Web/Pages/Docs/Mcp.cshtml b/src/Nalu.Web/Pages/Docs/Mcp.cshtml new file mode 100644 index 0000000..3405717 --- /dev/null +++ b/src/Nalu.Web/Pages/Docs/Mcp.cshtml @@ -0,0 +1,133 @@ +@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."; +} + +
+
+
+ Docs / MCP Server +
+

MCP Server

+

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

+
+
+ +
+
+ + +
+

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. +

+
+ + +
+

Configurar no Claude Code

+

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

+
+
{
+  "mcpServers": {
+    "nalu": {
+      "command": "npx",
+      "args": ["-y", "@@naluai/mcp-server"],
+      "env": {
+        "NALU_API_KEY": "SUA_API_KEY"
+      }
+    }
+  }
+}
+
+

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

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

Configurar no Cursor

+

Acesse Settings → MCP → Add Server e adicione:

+
+
Name: NALU AI
+URL:  https://api.naluai.com/mcp
+Headers:
+  Authorization: Bearer SUA_API_KEY
+
+
+ + +
+

Ferramentas disponíveis

+

Após conectar, o agente de IA 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"), + }) + { + + + + + } + +
FerramentaDescrição
@t.Item1@t.Item2
+
+
+ + +
+

Exemplo de uso no Claude Code

+
+

Prompt para o Claude:

+

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

+

Claude chama automaticamente:

+
validate_cpf({
+  "agent_input": "Qual o seu CPF?",
+  "user_input":  "meu cpf é 111.444.777-35"
+})
+
+
+ + +
+
diff --git a/src/Nalu.Web/Pages/Docs/Mcp.cshtml.cs b/src/Nalu.Web/Pages/Docs/Mcp.cshtml.cs new file mode 100644 index 0000000..43419a7 --- /dev/null +++ b/src/Nalu.Web/Pages/Docs/Mcp.cshtml.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Docs; + +public class McpModel : PageModel +{ + public void OnGet() { } +} diff --git a/src/Nalu.Web/Pages/Docs/N8n.cshtml b/src/Nalu.Web/Pages/Docs/N8n.cshtml new file mode 100644 index 0000000..c08e689 --- /dev/null +++ b/src/Nalu.Web/Pages/Docs/N8n.cshtml @@ -0,0 +1,256 @@ +@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."; +} + +
+
+
+ Docs / N8N +
+

Integrando com N8N

+

Sem código. Arraste um nó, cole a URL, mapeie os campos. Pronto.

+
+
+ +
+
+ + +
+

O que você vai precisar

+
    +
  • + 1. + Uma conta NALU AI com uma API Key — crie grátis aqui. A key fica no seu Painel, formato nalu-XXXXXXXXXX. +
  • +
  • + 2. + N8N rodando (cloud ou self-hosted). Qualquer versão moderna funciona. +
  • +
  • + 3. + Um fluxo que já recebe a mensagem do usuário — pode ser Webhook, Typebot, Chatwoot, Z-API, Telegram, etc. +
  • +
+
+ + +
+

Conceito em 30 segundos

+

+ O NALU é uma API REST. No N8N, qualquer API REST é chamada com o nó + HTTP Request. Você manda o que o usuário digitou, + o NALU devolve o dado extraído e limpo — ou uma sugestão de como perguntar de novo. +

+
+
Entrada: "meu cpf é 111.444.777-35"
+
+
NALU: obtained: true · extracted_value: "111.444.777-35"
+
Entrada: "não lembro agora"
+
+
NALU: obtained: false · suggestion_to_agent: "Tudo bem! Pode digitar só os números do CPF?"
+
+
+ + +
+

Configurando o nó HTTP Request

+

Exemplo com validação de CPF. O processo é idêntico para todos os outros validadores — só muda a URL.

+ +
+ + +
+
1
+
+
Arraste um nó HTTP Request
+

No painel do N8N, clique no + para adicionar nó. Busque por "HTTP Request" e selecione.

+
+
+ + +
+
2
+
+
Configure o método e a URL
+
+
Method: POST
+
URL: https://api.naluai.com/v1/extract/cpf
+
+

Troque cpf pelo validador que precisar: cep, name, email, etc.

+
+
+ + +
+
3
+
+
Adicione a autenticação
+

Em Authentication, selecione Header Auth e configure:

+
+
Name: Authorization
+
Value: Bearer nalu-SUA_API_KEY
+
+
+ Dica: Guarde a key em Credentials → Header Auth no N8N. Assim você reutiliza em todos os nós NALU sem copiar a key toda vez. +
+
+
+ + +
+
4
+
+
Configure o corpo (Body)
+

Selecione Body → JSON e use expressões N8N para mapear os dados:

+
+
{
+  "user_input":    "{{ $json.message }}",
+  "agent_input":   "Qual o seu CPF?",
+  "agent_context": "Agente de cadastro de clientes",
+  "language":      "pt-BR"
+}
+
+

+ $json.message é o campo que vem do nó anterior com a resposta do usuário. + O nome exato depende do seu trigger (Webhook, Typebot, etc.) — use o painel de expressões do N8N para localizar. +

+
+
+ + +
+
5
+
+
Use o resultado com um nó IF
+

Conecte um nó IF na saída do HTTP Request:

+
+
Condição: {{ $json.obtained }} igual a true
+
+
+
+
✓ Ramo TRUE
+

Dado extraído. Use $json.extracted_value no próximo passo do fluxo.

+
+
+
✗ Ramo FALSE
+

Não encontrou. Envie $json.suggestion_to_agent como resposta ao usuário e aguarde nova tentativa.

+
+
+
+
+ +
+
+ + +
+

Fluxo completo de coleta de dados

+

Exemplo: coletar CPF em um chatbot de cobrança via WhatsApp.

+
+
[ Webhook — mensagem do WhatsApp ]
+         ↓
+[ HTTP Request → NALU /extract/cpf ]
+  body: { user_input: mensagem do usuário }
+         ↓
+[ IF: obtained === true ]
+    ↓ TRUE                    ↓ FALSE
+[ Salvar CPF no BD ]    [ Enviar suggestion_to_agent ]
+[ Próxima etapa ]       [ Aguardar nova resposta ]
+                        [ (volta para o Webhook) ]
+
+
+ + +
+

Exemplo avançado: validate_reply

+

+ O validate_reply analisa a resposta do usuário + no contexto do que o agente disse. Útil para detectar contrapropostas, confirmações e handoffs. +

+
+
{
+  "agent_message": "Posso parcelar em 20x de R$100. Topa?",
+  "user_reply":    "{{ $json.message }}",
+  "agent_context": "Agente de negociação de parcelas",
+  "language":      "pt-BR"
+}
+
+

A resposta inclui o campo reply_type. Use um nó Switch (em vez de IF) para rotear:

+
+
[ Switch: $json.reply_type ]
+
+  "confirmation"      → prosseguir com fechamento
+  "counter_proposal"  → avaliar nova proposta ($json.extracted_value)
+  "rejection"         → oferecer alternativa
+  "handoff"           → transferir para atendente humano
+  "cancel"            → acionar fluxo de retenção
+  "unclear"           → pedir esclarecimento
+
+
+ + +
+

Campos disponíveis na resposta

+

Todos acessíveis como $json.nome_do_campo no nó seguinte:

+ + + + + + + + + + + + + + + +
CampoO que contém
obtainedtrue/false — se o dado foi extraído
extracted_valueO dado limpo e normalizado (ex: "111.444.777-35")
certainfalse = pedir confirmação antes de salvar
confidencelow / medium / high
suggestion_to_agentFrase pronta para enviar ao usuário quando obtained=false
reply_typeSó validate_reply: confirmation, counter_proposal, handoff, etc.
+
+ + +
+
Dicas para não errar
+
    +
  • Limite de tentativas: coloque um contador no N8N. Se o usuário errar 3 vezes, escale para humano — não deixe o bot em loop infinito.
  • +
  • certain: false: quando true mas certain é false, confirme com o usuário antes de salvar. Ex: "Encontrei o nome João Silva, está correto?"
  • +
  • Campos do CEP: além de extracted_value, o validate_cep retorna um objeto com logradouro, bairro, cidade e estado separados — útil para preencher formulários.
  • +
  • Erros 429: créditos acabaram. Trate com um nó IF no status code antes do IF do obtained.
  • +
  • agent_context: sempre envie — melhora muito a qualidade das sugestões contextuais quando o usuário não responde o que foi pedido.
  • +
+
+ + +
+

URLs de referência rápida

+
+
POST https://api.naluai.com/v1/extract/cpf
+POST https://api.naluai.com/v1/extract/cep
+POST https://api.naluai.com/v1/extract/cnpj
+POST https://api.naluai.com/v1/extract/email
+POST https://api.naluai.com/v1/extract/phone
+POST https://api.naluai.com/v1/extract/name
+POST https://api.naluai.com/v1/extract/yes-no
+POST https://api.naluai.com/v1/extract/birthdate
+POST https://api.naluai.com/v1/extract/handoff
+POST https://api.naluai.com/v1/extract/cancel-intent
+POST https://api.naluai.com/v1/extract/company-name
+POST https://api.naluai.com/v1/extract/plate-br
+POST https://api.naluai.com/v1/extract/reply        ← usa agent_message + user_reply
+
+
+ + + +
+
diff --git a/src/Nalu.Web/Pages/Docs/N8n.cshtml.cs b/src/Nalu.Web/Pages/Docs/N8n.cshtml.cs new file mode 100644 index 0000000..4d0d570 --- /dev/null +++ b/src/Nalu.Web/Pages/Docs/N8n.cshtml.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Docs; + +public class N8nModel : PageModel +{ + public void OnGet() { } +} diff --git a/src/Nalu.Web/Pages/Docs/Quickstart.cshtml b/src/Nalu.Web/Pages/Docs/Quickstart.cshtml new file mode 100644 index 0000000..4f79642 --- /dev/null +++ b/src/Nalu.Web/Pages/Docs/Quickstart.cshtml @@ -0,0 +1,175 @@ +@page "/docs/quickstart" +@model Nalu.Web.Pages.Docs.QuickstartModel +@{ + ViewData["Title"] = "Quickstart — NALU AI Docs"; + ViewData["Description"] = "Primeira chamada ao NALU AI em menos de 2 minutos. cURL, JavaScript, Python e C#."; +} + +
+
+
+ Docs / Quickstart +
+

Quickstart

+

Primeira chamada em menos de 2 minutos.

+
+
+ +
+
+ + +
+

1. Obtenha sua API Key

+

+ Crie uma conta grátis + e acesse o Painel. + Sua API Key aparece logo na primeira tela (formato nalu-XXXXXXXXXX). +

+
+ Plano Free inclui 3.000 créditos/mês sem cartão. +
+
+ + +
+

2. Faça a primeira chamada

+

Valide um CPF em linguagem natural:

+ +
+
+ + + + +
+ +
+
curl https://api.naluai.com/v1/extract/cpf \
+  -H "Authorization: Bearer SUA_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "agent_input": "Qual o seu CPF?",
+    "user_input": "meu cpf é 111.444.777-35",
+    "language": "pt-BR"
+  }'
+
+ + + + + + +
+
+ + +
+

3. Interprete a resposta

+
+
{
+  "obtained": true,
+  "extracted_value": "111.444.777-35",
+  "certain": true,
+  "confidence": "high",
+  "value_format": "string",
+  "suggestion_to_agent": null
+}
+
+ + + + + + + + + + + + + + + +
CampoTipoDescrição
obtainedbooltrue = dado extraído com sucesso
extracted_valuestring?Valor normalizado ou null
certainboolfalse = pedir confirmação ao usuário
confidencestringlow / medium / high
suggestion_to_agentstring?Frase sugerida quando obtained=false
+
+ + + +
+
+ +@section Scripts { + +} diff --git a/src/Nalu.Web/Pages/Docs/Quickstart.cshtml.cs b/src/Nalu.Web/Pages/Docs/Quickstart.cshtml.cs new file mode 100644 index 0000000..969002b --- /dev/null +++ b/src/Nalu.Web/Pages/Docs/Quickstart.cshtml.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Docs; + +public class QuickstartModel : PageModel +{ + public void OnGet() { } +} diff --git a/src/Nalu.Web/Pages/Index.cshtml b/src/Nalu.Web/Pages/Index.cshtml new file mode 100644 index 0000000..21ce0fc --- /dev/null +++ b/src/Nalu.Web/Pages/Index.cshtml @@ -0,0 +1,399 @@ +@page +@model IndexModel +@{ + ViewData["Title"] = "Seu chatbot está gravando 'Bom Dia' como nome?"; + ViewData["Description"] = "NALU AI extrai dados reais de diálogos agente/usuário. CPF, nome, CEP, parcelas. A partir de R$ 0,0058 por validação."; +} + + +
+
+
+ 13 validadores · MCP + REST · 3.000 créditos grátis +
+

+ Seu chatbot está gravando
+ "Bom Dia" como nome do cliente? +

+

+ NALU AI extrai o que o usuário realmente disse — nome, CPF, CEP, parcelas — + sem confundir saudação com dado. Integra em 30 segundos. +

+ + + +
    +
  • ✓ 3.000 créditos grátis por mês
  • +
  • ✓ Setup em 30 segundos
  • +
  • ✓ Funciona com n8n, Make, Claude Code, Cursor
  • +
+ +
+ R$ 0,0058 + por validação no plano Starter. +

Menos de 1 centavo para nunca mais gravar "Bom Dia" como nome.

+
+
+
+ + +
+
+

O problema (que todo mundo já teve)

+ + +
+
+
BOT TRADICIONAL
+
+
Agente: Olá! Qual o seu nome?
+
Usuário: Bom dia! Me chamo João Silva
+
❌ Gravou: "Bom Dia Me Chamo João Silva"
+
+
+
+
COM NALU AI (validate_name)
+
+
extracted_value: "João Silva"
+
certain: true
+
confidence: "high"
+
+
Custo: R$ 0,0058 (3 créditos)
+
+
+ + +
+
+
BOT TRADICIONAL
+
+
Agente: Posso parcelar em 20x de R$100. Topa?
+
Usuário: Bora em 48?
+
❌ Bot entendeu: R$ 48,00
+
+
+
+
COM NALU AI (validate_reply) 5 créditos
+
+
reply_type: counter_proposal
+
extracted_value: "48 parcelas"
+
suggestion: "Cliente propõe 48 parcelas..."
+
+
Custo: R$ 0,0097 (5 créditos)
+
+
+ + +
+
+
BOT TRADICIONAL
+
+
Agente: Qual seu CEP?
+
Usuário: É o 01310-100
+
❌ Regex falhou: "É o 01310-100" não é só dígitos
+
+
+
+
COM NALU AI (validate_cep)
+
+
extracted_value: "01310-100"
+
cidade: "São Paulo"
+
bairro: "Bela Vista"
+
+
Custo: R$ 0,0058 (3 créditos)
+
+
+
+
+ + +
+
+

Como funciona

+
+
+
📨
+

1. Envie o diálogo

+

Agente + resposta do usuário. Dois campos. Nada mais.

+
+
+
🧠
+

2. NALU extrai

+

Extração semântica em camadas. Resultado normalizado e validado.

+
+
+
+

3. Use o dado limpo

+

obtained: true + valor validado. Sem regex, sem alucinação.

+
+
+
+
+ + +
+
+ Quadrinho: bot gravando Bom Dia como nome +
+
+ + +
+
+

13 validadores prontos

+

Validadores com IA — extração semântica em múltiplas camadas

+ + +
+
+ + +
+
+

Máquina de estados inteligente

+

Validadores podem ser encadeados em fluxos completos.

+
+
+[validate_name]     → confirma identidade              (3 créditos)
+    ↓
+[validate_reply]    → "20x de R$100, topa?"            (5 créditos)
+    ↓ reply_type: counter_proposal
+    → extracted: 48 parcelas
+    → agente avalia se pode oferecer 48x
+    ↓ reply_type: confirmation → prossegue
+    ↓ reply_type: rejection    → oferece alternativa
+    ↓ reply_type: handoff      → transfere para humano
+
+Custo total: 8 créditos = R$ 0,0155 no Starter.
+Mais barato que perder a venda.
+
+
+
+ + +
+
+

+ Com o validate_reply do NALU AI, o bot entende que "48" no contexto + de uma oferta de parcelas é uma contraproposta — não um valor. + Custo por análise: R$ 0,0097. Menos que o cafezinho. +

+ Quadrinho: 48 parcelas vs R$48 +
+
+ + +
+
+

+ Com o validate_handoff, o bot identifica que o cliente quer falar com um humano + — mesmo quando não diz isso explicitamente. + Custo por detecção: R$ 0,0058. +

+
+
+
Sem NALU
+ Bot ignorando pedido de atendente humano +
+
+
Com NALU (validate_handoff)
+ NALU identificando intenção de falar com humano +
+
+
+
+ + +
+
+

Integração em 30 segundos

+
+
+ + + + +
+ +
+
curl https://api.naluai.com/v1/extract/name \
+  -H "Authorization: Bearer SEU_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "agent_input": "Qual o seu nome?",
+    "user_input": "Bom dia! Me chamo João Silva",
+    "language": "pt-BR"
+  }'
+
+# Resposta:
+# {
+#   "obtained": true,
+#   "extracted_value": "João Silva",
+#   "confidence": "high",
+#   "certain": true
+# }
+
+ + + + + + +
+
+
+ + +
+
+

Preços

+

+ R$ 0,0058 + por validação no plano Starter. +

+ +
+
+
Free
+
R$ 0
+
3.000 créditos/mês
+ Começar grátis +
+
+
Popular
+
Starter
+
R$ 0,0058
+
por validação
+
R$ 29/mês · 15.000 créditos
+ Assinar → +
+
+
Indie
+
R$ 0,0041
+
por validação
+
R$ 69/mês · 50.000 créditos
+ Assinar → +
+
+
Pro
+
R$ 0,0024
+
por validação
+
R$ 199/mês · 250.000 créditos
+ Assinar → +
+
+ + +
+
💡 Fazendo a conta
+

+ Um chatbot de cobrança faz ~500 validações por mês. + Com o plano Starter, isso custa R$ 0,95. + Menos que um café. Para nunca mais perder um cliente + por um bot que confundiu 48 parcelas com R$ 48. +

+

+ Qual o custo de perder uma venda por um erro do bot? +

+
+
+
+ + +
+
+

FAQ

+
+ @foreach (var faq in Model.Faqs) + { +
+
@faq.Q
+
@faq.A
+
+ } +
+
+
+ + +
+
+

Pare de perder dados (e clientes) por regex ruim.

+

3.000 créditos grátis por mês. Sem cartão. Setup em 30 segundos.

+ + Começar grátis → + +

A partir de R$ 0,0058 por validação. Menos que uma gota de café.

+
+
+ +@section Scripts { + +} diff --git a/src/Nalu.Web/Pages/Index.cshtml.cs b/src/Nalu.Web/Pages/Index.cshtml.cs new file mode 100644 index 0000000..2ff7d27 --- /dev/null +++ b/src/Nalu.Web/Pages/Index.cshtml.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages; + +public class IndexModel : PageModel +{ + public record FaqItem(string Q, string A); + + public IReadOnlyList Validators { get; } = + [ + new("🔤", "validate_name", "Extrai nome completo, ignora saudações e títulos.", 3), + new("🆔", "validate_cpf", "Valida CPF com mod 11. Formata XXX.XXX.XXX-XX.", 3), + new("📮", "validate_cep", "Extrai CEP e retorna endereço enriquecido.", 3), + new("📱", "validate_phone", "Extrai telefone com DDD. Valida DDDs ANATEL.", 3), + new("✉️", "validate_email", "Extrai email e corrige typos (gmail→gmail.com).", 3), + new("🌍", "validate_postal_code", "Código postal internacional (não CEP).", 3), + new("☑️", "validate_yes_no", "Detecta sim/não em qualquer idioma e forma indireta.", 3), + new("🎂", "validate_birthdate", "Data de nascimento em qualquer formato. Detecta menores.", 3), + new("🤝", "validate_handoff", "Detecta intenção de falar com humano (urgência 1-3).", 3), + new("🚫", "validate_cancel_intent","Diferencia cancelamento de serviço vs operação atual.", 3), + new("🏢", "validate_cnpj", "Valida CNPJ com mod 11. Formata XX.XXX.XXX/XXXX-XX.", 3), + new("🚗", "validate_plate_br", "Placa Mercosul e formato antigo. Aceita por extenso.", 3), + new("🧠", "validate_reply", "Analisa contexto conversacional. Detecta contrapropostas, handoffs, cancelamentos.", 5, IsNew: true), + ]; + + public IReadOnlyList Faqs { get; } = + [ + new("O que acontece quando meus créditos acabam?", + "Suas chamadas retornam 429 com sugestão de upgrade. Sem cobrança surpresa. Seus dados e chaves continuam intactos."), + new("Por que validate_reply custa 5 créditos?", + "Requer análise de contexto aprofundada sobre o par agente+usuário como unidade semântica. Os demais validadores usam extração em camadas mais rápida."), + new("Funciona com n8n e Make?", + "Sim. É uma API REST padrão. Se o seu tool aceita HTTP, funciona com NALU. Também disponível via MCP para Claude Code e Cursor."), + new("Posso usar o MCP com Claude Code?", + "Sim. Adicione o servidor NALU ao Claude Code e chame os validadores como ferramentas nativas. Ver /docs/mcp."), + new("Meus dados ficam armazenados?", + "Não. Os diálogos enviados são usados apenas para processar a chamada e descartados. Não armazenamos o conteúdo das conversas."), + ]; + + public void OnGet() { } +} diff --git a/src/Nalu.Web/Pages/Login.cshtml b/src/Nalu.Web/Pages/Login.cshtml new file mode 100644 index 0000000..800478e --- /dev/null +++ b/src/Nalu.Web/Pages/Login.cshtml @@ -0,0 +1,71 @@ +@page "/login" +@model Nalu.Web.Pages.LoginModel +@{ + ViewData["Title"] = "Entrar — NALU AI"; + ViewData["Description"] = "Entre com sua conta Google, Microsoft ou GitHub para acessar o painel e sua API key."; + Layout = "_Layout"; +} + +
+
+ +
+ + NALU + AI + +

Entre para acessar seu painel e API key

+
+ + +
+

Entrar na sua conta

+ + + +

+ Ao entrar, você concorda com nossos + Termos e + Privacidade. +

+
+ +

+ Novo por aqui? Sua conta é criada automaticamente no primeiro login. +
Você ganha 3.000 créditos grátis. +

+
+
diff --git a/src/Nalu.Web/Pages/Login.cshtml.cs b/src/Nalu.Web/Pages/Login.cshtml.cs new file mode 100644 index 0000000..950d139 --- /dev/null +++ b/src/Nalu.Web/Pages/Login.cshtml.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages; + +public class LoginModel : PageModel +{ + public string? ReturnUrl { get; private set; } + + public void OnGet(string? returnUrl = null) + { + ReturnUrl = returnUrl ?? "/painel"; + } + + public IActionResult OnGetGoogle(string? returnUrl = null) => + Challenge(new Microsoft.AspNetCore.Authentication.AuthenticationProperties + { + RedirectUri = $"/auth/callback?returnUrl={Uri.EscapeDataString(returnUrl ?? "/painel")}" + }, "Google"); + + public IActionResult OnGetMicrosoft(string? returnUrl = null) => + Challenge(new Microsoft.AspNetCore.Authentication.AuthenticationProperties + { + RedirectUri = $"/auth/callback?returnUrl={Uri.EscapeDataString(returnUrl ?? "/painel")}" + }, "Microsoft"); + + public IActionResult OnGetGitHub(string? returnUrl = null) => + Challenge(new Microsoft.AspNetCore.Authentication.AuthenticationProperties + { + RedirectUri = $"/auth/callback?returnUrl={Uri.EscapeDataString(returnUrl ?? "/painel")}" + }, "GitHub"); +} diff --git a/src/Nalu.Web/Pages/Painel/Index.cshtml b/src/Nalu.Web/Pages/Painel/Index.cshtml new file mode 100644 index 0000000..da4898f --- /dev/null +++ b/src/Nalu.Web/Pages/Painel/Index.cshtml @@ -0,0 +1,160 @@ +@page "/painel" +@model Nalu.Web.Pages.Painel.IndexModel +@{ + ViewData["Title"] = "Painel — NALU AI"; + var pct = Model.CreditsLimit > 0 ? Math.Min(100, Model.CreditsUsed * 100 / Model.CreditsLimit) : 0; + var barColor = pct >= 90 ? "bg-red-500" : pct >= 70 ? "bg-amber-500" : "bg-nalu-500"; +} + + +@if (Model.NewApiKey != null) +{ +
+
+
🎉
+

Bem-vindo ao NALU AI!

+

+ Sua conta foi criada com 3.000 créditos grátis. + Guarde sua API key — ela só é exibida uma vez. +

+
+
Sua API Key
+
@Model.NewApiKey
+
+ + +
+
+ +} + +
+ +
+
+ @if (!string.IsNullOrEmpty(Model.UserPicture)) + { + Avatar + } + else + { +
+ @(Model.UserName.Length > 0 ? Model.UserName[0].ToString().ToUpper() : "?") +
+ } +
+
@Model.UserName
+
@Model.UserEmail
+
+
+
+ @Model.Plan + Sair +
+
+ + +
+
+

Uso este mês

+ Reseta em @(new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1).AddMonths(1).ToString("dd/MM")) +
+
+
@Model.CreditsUsed.ToString("N0")
+
/ @Model.CreditsLimit.ToString("N0") créditos
+
+
+
+
+
+ @(Model.CreditsLimit - Model.CreditsUsed) restantes + @pct% +
+ @if (Model.Plan == "free") + { + + } +
+ + +
+
+

API Keys

+
+ + @if (Model.Keys.Count == 0) + { +

Nenhuma key ativa.

+ } + else + { +
+ @foreach (var k in Model.Keys) + { +
+
+
@(k.Label ?? "API Key")
+
+ @(k.Key[..Math.Min(20, k.Key.Length)])… +
+ @if (k.LastUsedAt.HasValue) + { +
Último uso: @k.LastUsedAt.Value.ToString("dd/MM/yyyy HH:mm") UTC
+ } +
+
+ +
+ + +
+
+
+ } +
+ } + + +
+
Como usar
+
curl https://api.naluai.com/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"}'
+
+
+
+ +@section Scripts { + +} diff --git a/src/Nalu.Web/Pages/Painel/Index.cshtml.cs b/src/Nalu.Web/Pages/Painel/Index.cshtml.cs new file mode 100644 index 0000000..e1825fd --- /dev/null +++ b/src/Nalu.Web/Pages/Painel/Index.cshtml.cs @@ -0,0 +1,67 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using MongoDB.Driver; +using Nalu.Web.Data; +using Nalu.Web.Data.Models; +using Nalu.Web.Data.Repositories; +using Nalu.Web.Services; + +namespace Nalu.Web.Pages.Painel; + +[Authorize] +public class IndexModel( + ApiKeyRepository apiKeys, + MongoDbContext db, + IConfiguration config) : PageModel +{ + public List Keys { get; private set; } = []; + public int CreditsUsed { get; private set; } + public int CreditsLimit { get; private set; } + public string? NewApiKey { get; private set; } + public string UserName { get; private set; } = ""; + public string UserEmail { get; private set; } = ""; + public string UserPicture { get; private set; } = ""; + public string Plan { get; private set; } = "free"; + + public async Task OnGetAsync(CancellationToken ct) + { + UserName = User.FindFirstValue(ClaimTypes.Name) ?? ""; + UserEmail = User.FindFirstValue(ClaimTypes.Email) ?? ""; + UserPicture = User.FindFirstValue("picture") ?? ""; + Plan = User.FindFirstValue("plan") ?? "free"; + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + Keys = await apiKeys.GetByUserAsync(userId, ct); + + // Get credit usage from UsageMonthly (same collection CreditService writes to) + if (Keys.Count > 0 && db.IsConnected) + { + var yearMonth = DateTime.UtcNow.ToString("yyyy-MM"); + var monthly = await db.UsageMonthly + .Find(u => u.ApiKey == Keys[0].Key && u.YearMonth == yearMonth) + .FirstOrDefaultAsync(ct); + CreditsUsed = monthly?.TotalCreditsUsed ?? 0; + } + + var planConfig = config.GetSection($"Plans:{Plan}"); + CreditsLimit = planConfig.GetValue("credits_per_month"); + if (CreditsLimit == 0) CreditsLimit = 3000; + + // Welcome modal — shown once + NewApiKey = TempData["NewApiKey"] as string; + } + + public async Task OnPostRevokeAsync(string key, CancellationToken ct) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var userKeys = await apiKeys.GetByUserAsync(userId, ct); + + // Only revoke keys owned by this user + if (userKeys.Any(k => k.Key == key)) + await apiKeys.RevokeAsync(key, ct); + + return RedirectToPage(); + } +} diff --git a/src/Nalu.Web/Pages/Playground.cshtml b/src/Nalu.Web/Pages/Playground.cshtml new file mode 100644 index 0000000..d86f2c1 --- /dev/null +++ b/src/Nalu.Web/Pages/Playground.cshtml @@ -0,0 +1,320 @@ +@page "/playground" +@model Nalu.Web.Pages.PlaygroundModel +@{ + ViewData["Title"] = "Playground — NALU AI"; + ViewData["Description"] = "Teste os validadores NALU AI sem cadastro. 10 chamadas por dia grátis."; +} + +
+
+

Playground

+

Teste qualquer validador sem cadastro. 10 chamadas por dia, grátis.

+
+
+ +
+
+
+ + +
+
Validador
+
+ @foreach (var v in new[] { + ("cpf", "validate_cpf", "3 cr."), + ("cep", "validate_cep", "3 cr."), + ("phone-br", "validate_phone_br", "3 cr."), + ("email", "validate_email", "3 cr."), + ("cnpj", "validate_cnpj", "3 cr."), + ("plate-br", "validate_plate_br", "3 cr."), + ("name", "validate_full_name", "3 cr."), + ("yes-no", "validate_yes_no", "3 cr."), + ("birthdate", "validate_birthdate", "3 cr."), + ("handoff", "validate_handoff", "3 cr."), + ("cancel-intent", "validate_cancel_intent", "3 cr."), + ("company-name", "validate_company_name", "3 cr."), + ("reply", "validate_reply", "5 cr."), + }) + { + var isSelected = Model.SelectedValidator == v.Item1 || (Model.SelectedValidator == null && v.Item1 == "cpf"); + + } +
+
+ + +
+ + +
+
+ + agent_context +
+ +
+ + +
+
+ + agent_input +
+ +
+ + + + + +
+
+ + user_input +
+ +
+ + +
+ + +
+ + +
+ + + +
+
+
+
+ +@section Scripts { + +} diff --git a/src/Nalu.Web/Pages/Playground.cshtml.cs b/src/Nalu.Web/Pages/Playground.cshtml.cs new file mode 100644 index 0000000..24ff89e --- /dev/null +++ b/src/Nalu.Web/Pages/Playground.cshtml.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages; + +public class PlaygroundModel : PageModel +{ + public string? SelectedValidator { get; private set; } + + public void OnGet(string? validator = null) + { + SelectedValidator = validator; + } +} diff --git a/src/Nalu.Web/Pages/Precos.cshtml b/src/Nalu.Web/Pages/Precos.cshtml new file mode 100644 index 0000000..9976f74 --- /dev/null +++ b/src/Nalu.Web/Pages/Precos.cshtml @@ -0,0 +1,232 @@ +@page +@model Nalu.Web.Pages.PrecosModel +@{ + ViewData["Title"] = "Preços — a partir de R$ 0,0058 por validação"; + ViewData["Description"] = "Planos NALU AI: Free, Starter (R$29), Indie (R$69), Pro (R$199). A partir de R$ 0,0058 por validação. Menos que uma gota de café."; + + // Helper: render the CTA button for a given plan + string PlanButton(string plan, string defaultCss, string activeCss) + { + if (!Model.IsAuthenticated) + return $"Entrar / Cadastrar →"; + if (Model.CurrentPlan == plan) + return $"Plano atual ✓"; + return $"Assinar →"; + } +} + +@if (Model.CheckoutError != null) +{ +
+ @Model.CheckoutError +
+} + + +
+
+

Quanto custa consertar seu chatbot?

+
R$ 0,0058
+

por validação no plano Starter.

+
+

Menos que uma gota de café por chamada.

+

Menos que o custo de um SMS.

+

Menos que o prejuízo de UM cliente que desistiu

+

porque o bot não entendeu o que ele disse.

+
+
+
+ + +
+
+
+ + +
+
Free
+
Para testar e projetos pessoais
+
R$ 0
+
para sempre
+
    +
  • ✓ 3.000 créditos/mês
  • +
  • ✓ 13 validadores
  • +
  • ✓ Playground
  • +
  • ✓ Docs completa
  • +
  • – Email: sem suporte
  • +
+ @if (!Model.IsAuthenticated) + { + Entrar / Cadastrar → + } + else if (Model.CurrentPlan == "free") + { + Plano atual ✓ + } + else + { + Fazer downgrade + } +
+ + +
+
Mais popular
+
Starter
+
Para startups e MVPs
+
R$ 0,0058
+
por validação
+
R$ 29/mês · 15.000 créditos
+
    +
  • ✓ 15.000 créditos/mês
  • +
  • ✓ Tudo do Free
  • +
  • ✓ Dashboard
  • +
  • ✓ Email 72h
  • +
+ @Html.Raw(PlanButton("starter", + "bg-nalu-600 text-white rounded-xl py-2.5 text-sm font-bold hover:bg-nalu-700 transition-colors", + "bg-nalu-100 text-nalu-700 rounded-xl py-2.5 text-sm font-bold")) +
+ + +
+
Indie
+
Para produtos em crescimento
+
R$ 0,0041
+
por validação
+
R$ 69/mês · 50.000 créditos
+
    +
  • ✓ 50.000 créditos/mês
  • +
  • ✓ Tudo do Starter
  • +
  • ✓ Email 24h
  • +
  • ✓ Priority queue
  • +
+ @Html.Raw(PlanButton("indie", + "border border-gray-300 rounded-xl py-2.5 text-sm font-semibold hover:border-nalu-500 hover:text-nalu-600 transition-colors", + "border border-nalu-200 text-nalu-700 rounded-xl py-2.5 text-sm font-semibold")) +
+ + +
+
Pro
+
Para produtos em escala
+
R$ 0,0024
+
por validação
+
R$ 199/mês · 250.000 créditos
+
    +
  • ✓ 250.000 créditos/mês
  • +
  • ✓ Tudo do Indie
  • +
  • ✓ SLA 99%
  • +
  • ✓ Suporte 8h
  • +
+ @Html.Raw(PlanButton("pro", + "border border-gray-300 rounded-xl py-2.5 text-sm font-semibold hover:border-nalu-500 hover:text-nalu-600 transition-colors", + "border border-nalu-200 text-nalu-700 rounded-xl py-2.5 text-sm font-semibold")) +
+
+
+
+ + +
+
+

Quanto custa cada validador?

+
+ + + + + + + + + + + + + + + + + + + + + + + +
ValidadorCréditosStarterPro
CPF, CEP, CNPJ, email, telefone, placa, CEP internacional, nome, sim/não, data nasc., handoff, cancelamento, empresa3R$ 0,0058R$ 0,0024
+ 🧠 validate_reply (análise de contexto) + Premium + 5R$ 0,0097R$ 0,0040
+
+
+
+ + +
+
+

Quanto rende cada plano?

+ +
+
💡 Exemplo real: chatbot de cobrança via WhatsApp
+

Validações típicas por mês:

+
    +
  • • 200 extrações de nome (×3 cred = 600 cred)
  • +
  • • 200 validações de CPF (×3 cred = 600 cred)
  • +
  • • 100 análises de contexto reply (×5 cred = 500 cred)
  • +
  • • Total: 1.700 créditos = cabe no Free!
  • +
+

+ Cresceu? Com o Starter (R$ 29/mês), o mesmo bot atende 8× mais clientes + pelo custo de R$ 0,0058 por validação. +

+
+ +
+

+ O café que você tomou hoje custou mais que 1.000 validações. +

+
+
+
+ + +
+
+

Dúvidas sobre preço

+
+
+
E se meus créditos acabarem?
+
Chamadas retornam 429 com sugestão de upgrade. Sem cobrança surpresa. Seus dados e chaves continuam intactos.
+
+
+
Por que validate_reply custa 5 créditos?
+
Requer análise de contexto aprofundada sobre o par agente+usuário. Os demais validadores com IA usam extração semântica em camadas e custam 3 créditos.
+
+
+
Posso mudar de plano?
+
Sim, a qualquer momento. Upgrade é imediato. Downgrade no próximo ciclo de cobrança.
+
+
+
Aceita Pix ou boleto?
+
Aceitamos cartão de crédito e débito via Stripe. Pix em breve.
+
+
+
Tem desconto anual?
+
Em breve (fase 2). Cadastre-se para ser notificado.
+
+
+
+
+ + +
+
+

Comece com 3.000 créditos grátis

+

Sem cartão. Sem prazo. Setup em 30 segundos.

+ + Criar conta grátis → + +
+
diff --git a/src/Nalu.Web/Pages/Precos.cshtml.cs b/src/Nalu.Web/Pages/Precos.cshtml.cs new file mode 100644 index 0000000..f008c4a --- /dev/null +++ b/src/Nalu.Web/Pages/Precos.cshtml.cs @@ -0,0 +1,20 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages; + +public class PrecosModel : PageModel +{ + public bool IsAuthenticated { get; private set; } + public string CurrentPlan { get; private set; } = "free"; + public string? CheckoutError { get; private set; } + + public void OnGet() + { + IsAuthenticated = User.Identity?.IsAuthenticated ?? false; + if (IsAuthenticated) + CurrentPlan = User.FindFirstValue("plan") ?? "free"; + + CheckoutError = TempData["CheckoutError"] as string; + } +} diff --git a/src/Nalu.Web/Pages/Precos/Sucesso.cshtml b/src/Nalu.Web/Pages/Precos/Sucesso.cshtml new file mode 100644 index 0000000..0557582 --- /dev/null +++ b/src/Nalu.Web/Pages/Precos/Sucesso.cshtml @@ -0,0 +1,22 @@ +@page "/precos/sucesso" +@model Nalu.Web.Pages.Precos.SucessoModel +@{ + ViewData["Title"] = "Assinatura confirmada — NALU AI"; +} + +
+
+
🎉
+

Assinatura confirmada!

+

Seu plano foi ativado. Os créditos estarão disponíveis no painel em alguns instantes.

+

Você receberá um e-mail de confirmação do Stripe.

+ +
+
diff --git a/src/Nalu.Web/Pages/Precos/Sucesso.cshtml.cs b/src/Nalu.Web/Pages/Precos/Sucesso.cshtml.cs new file mode 100644 index 0000000..727761d --- /dev/null +++ b/src/Nalu.Web/Pages/Precos/Sucesso.cshtml.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Precos; + +public class SucessoModel : PageModel +{ + public void OnGet(string? session_id) { } +} diff --git a/src/Nalu.Web/Pages/Shared/_ValidatorGrid.cshtml b/src/Nalu.Web/Pages/Shared/_ValidatorGrid.cshtml new file mode 100644 index 0000000..58310ad --- /dev/null +++ b/src/Nalu.Web/Pages/Shared/_ValidatorGrid.cshtml @@ -0,0 +1,21 @@ +@using Nalu.Web.Pages +@model IEnumerable + +
+ @foreach (var v in Model) + { +
+
+
@v.Icon @v.Name
+
+ @if (v.IsNew) + { + NEW + } + @v.Credits cr +
+
+

@v.Description

+
+ } +
diff --git a/src/Nalu.Web/Pages/Validadores/Index.cshtml b/src/Nalu.Web/Pages/Validadores/Index.cshtml new file mode 100644 index 0000000..7bc9615 --- /dev/null +++ b/src/Nalu.Web/Pages/Validadores/Index.cshtml @@ -0,0 +1,102 @@ +@page "/validadores" +@model Nalu.Web.Pages.Validadores.IndexModel +@{ + ViewData["Title"] = "Validadores — NALU AI"; + ViewData["Description"] = "13 validadores prontos para extrair dados reais de diálogos. CPF, CEP, nome, email, parcelas e mais."; +} + +
+
+

Validadores

+

13 validadores com IA. Extração semântica em camadas + sugestão contextual inteligente.

+
+
+ +
+
+ + +
+
+ 🇧🇷 +
+

Brasileiros

+

Validação com regras específicas do Brasil (mod 11, ANATEL, Mercosul...)

+
+
+ +
+ + +
+
+ 🌍 +
+

Universais

+

Funcionam em qualquer idioma e país

+
+
+ +
+ + +
+
+ Em breve +

Validadores universais

+
+
+
+ @foreach (var v in Model.ComingSoonUniversal) + { +
+ @v.Icon +
+
@v.Slug
+
@v.Description
+
+
+ } +
+

+ Quer priorizar algum? + Fale com a gente → +

+
+
+ + +
+
+ Em breve +

Validadores Brasil

+
+
+
+ @foreach (var v in Model.ComingSoonBrazilian) + { +
+ @v.Icon +
+
@v.Slug
+
@v.Description
+
+
+ } +
+

+ Quer priorizar algum? + Fale com a gente → +

+
+
+ + + + +
+
diff --git a/src/Nalu.Web/Pages/Validadores/Index.cshtml.cs b/src/Nalu.Web/Pages/Validadores/Index.cshtml.cs new file mode 100644 index 0000000..ff9652f --- /dev/null +++ b/src/Nalu.Web/Pages/Validadores/Index.cshtml.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Validadores; + +public class IndexModel : PageModel +{ + public record ComingSoonItem(string Icon, string Slug, string Description); + + public IReadOnlyList Brazilian { get; } = + [ + new("🆔", "validate_cpf", "Valida CPF com mod 11. Formata XXX.XXX.XXX-XX.", 3), + new("📮", "validate_cep", "Extrai CEP e retorna endereço enriquecido.", 3), + new("📱", "validate_phone_br", "Extrai telefone com DDD. Valida DDDs ANATEL.", 3), + new("🏢", "validate_cnpj", "Valida CNPJ com mod 11. Formata XX.XXX.XXX/XXXX-XX.", 3), + new("🚗", "validate_plate_br", "Placa Mercosul e formato antigo. Aceita por extenso.", 3), + ]; + + public IReadOnlyList Universal { get; } = + [ + new("✉️", "validate_email", "Extrai email e corrige typos de domínio (gmail→gmail.com).", 3), + new("🌍", "validate_postal_code", "Código postal internacional (não CEP).", 3), + new("🔤", "validate_full_name", "Extrai nome completo, ignora saudações e títulos.", 3), + new("☑️", "validate_yes_no", "Detecta sim/não em qualquer idioma e forma indireta.", 3), + new("🎂", "validate_birthdate", "Data de nascimento em qualquer formato. Detecta menores.", 3), + new("🤝", "validate_handoff", "Detecta intenção de falar com humano. Classifica urgência.", 3), + new("🚫", "validate_cancel_intent", "Diferencia cancelamento de serviço vs operação atual.", 3), + new("🏢", "validate_company_name", "Extrai nome de empresa. Detecta sufixos legais.", 3), + new("🧠", "validate_reply", "Analisa contexto conversacional. Detecta contrapropostas, handoffs, cancelamentos.", 5, IsNew: true), + ]; + + public IReadOnlyList ComingSoonUniversal { get; } = + [ + new("📅", "date-relative", "\"amanhã\", \"semana que vem\", \"dia 15\""), + new("🕐", "time", "\"de tardezinha\", \"lá pelas 3\", \"after lunch\""), + new("📍", "address-full", "Endereço completo extraído e estruturado"), + new("💰", "currency-amount", "Valores monetários em qualquer moeda"), + new("🔢", "number-in-context", "Números que mudam de sentido pelo contexto"), + new("👥", "count-people", "\"eu e minha esposa\" → 2 pessoas"), + new("⭐", "rating", "\"uns 7 eu acho\" → nota 7"), + new("😤", "sentiment", "Detecta insatisfação antes do handoff"), + new("🎯", "preference", "\"o do meio\" → extrai opção B"), + new("✅", "confirmation-with-changes", "\"sim mas é 200\" → confirma parcial"), + ]; + + public IReadOnlyList ComingSoonBrazilian { get; } = + [ + new("💰", "amount-brl", "Valores em R$ com contexto"), + new("🔑", "pix-key", "Chave Pix (CPF, email, telefone, aleatória)"), + new("📋", "renavam", "RENAVAM de veículo"), + new("📋", "ie-by-state", "Inscrição Estadual com validação por UF"), + new("📋", "pis-nis", "PIS/NIS/PASEP"), + ]; + + public void OnGet() { } +} diff --git a/src/Nalu.Web/Pages/Validadores/Reply.cshtml b/src/Nalu.Web/Pages/Validadores/Reply.cshtml new file mode 100644 index 0000000..51464b8 --- /dev/null +++ b/src/Nalu.Web/Pages/Validadores/Reply.cshtml @@ -0,0 +1,144 @@ +@page "/validadores/reply" +@model Nalu.Web.Pages.Validadores.ReplyModel +@{ + ViewData["Title"] = "validate_reply — Análise de contexto conversacional"; + ViewData["Description"] = "validate_reply analisa o par agente+usuário e detecta contrapropostas, evasivas, handoffs e ambiguidades. Resolve o bug das 48 parcelas vs R$48. 5 créditos."; +} + + +
+
+ +
+
🧠
+
+
+

validate_reply

+ ⭐ Premium · 5 créditos + NEW +
+

Análise de contexto conversacional.

+
+
+
+
+ + +
+
+

O que faz

+

+ Analisa a relação entre a mensagem do agente e a resposta do usuário. + Classifica o tipo de resposta, extrai o significado real considerando o contexto, + e sugere a próxima fala do agente. +

+ +

Resolve problemas como

+
    +
  • "Bora em 48?" → 48 parcelas, não R$48
  • +
  • "É 200" → número do endereço, não R$200
  • +
  • "Prefiro quinta" → quinta-feira, não 5 de algo
  • +
  • "Não aguento mais" → handoff detectado
  • +
  • "Quero o de cima" → upgrade de plano
  • +
+
+
+ + +
+
+

10 tipos de resposta detectados

+
+ @foreach (var rt in Model.ReplyTypes) + { +
+
+ @rt.Type +
+
@rt.Label
+
@rt.Example
+
+
+
+ } +
+
+
+ + +
+
+

Input / Output

+
+
+
Input
+
{
+  "agent_message": "Posso parcelar em 20x de R$100. Topa?",
+  "user_reply": "Bora em 48?",
+  "language": "pt-BR"
+}
+

Nota: este validador usa agent_message + user_reply em vez de agent_input + user_input.

+
+
+
Output
+
{
+  "obtained": true,
+  "reply_type": "counter_proposal",
+  "extracted_value": "48",
+  "value_type": "quantity",
+  "extracted_meaning": "Usuário propôs 48 parcelas como
+  contraproposta à oferta de 20 parcelas",
+  "confidence": 0.95,
+  "needs_clarification": false,
+  "suggestion_to_agent": "O cliente está propondo 48
+  parcelas em vez das 20 oferecidas. Deseja ajustar?"
+}
+
+
+
+
+ + +
+
+

Exemplos

+
+ @foreach (var ex in Model.Examples) + { +
+
+
@ex.Label
+ @ex.ExpectedType +
+
+
Agente: @ex.AgentMessage
+
Usuário: @ex.UserReply
+
+ @if (ex.Note != null) + { +
💡 @ex.Note
+ } +
+ } +
+
+
+ + +
+
+
+
5 créditos por chamada
+
+

No plano Starter: R$ 0,0097 por análise.

+

No plano Pro: R$ 0,0040 por análise.

+

Menos de 1 centavo para entender o que o cliente realmente quis dizer.

+
+ + Testar no playground → + +
+
+
diff --git a/src/Nalu.Web/Pages/Validadores/Reply.cshtml.cs b/src/Nalu.Web/Pages/Validadores/Reply.cshtml.cs new file mode 100644 index 0000000..989139a --- /dev/null +++ b/src/Nalu.Web/Pages/Validadores/Reply.cshtml.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Validadores; + +public class ReplyModel : PageModel +{ + public record ReplyTypeCard(string Type, string Label, string Example); + public record ExampleCard(string Label, string AgentMessage, string UserReply, string ExpectedType, string? Note = null); + + public IReadOnlyList ReplyTypes { get; } = + [ + new("answer", "Resposta direta", "\"Sim, meu nome é João Silva\""), + new("question", "Pergunta de volta", "\"Que horas vocês atendem?\""), + new("counter_proposal", "Contraproposta", "\"Bora em 48?\" (após oferta de 20x)"), + new("confirmation", "Confirmação positiva", "\"Pode mandar\", \"Ok\", \"Bora\""), + new("rejection", "Rejeição", "\"Não obrigado\", \"Não quero\""), + new("off_topic", "Fora do tópico", "\"Vocês vendem seguro?\" (após pedir CPF)"), + new("greeting", "Saudação", "\"Bom dia!\", \"Oi\""), + new("handoff", "Quer falar com humano", "\"Prefiro falar com alguém de verdade\""), + new("cancel", "Quer cancelar", "\"Para, cancela isso\", \"Quero sair\""), + new("unclear", "Ambíguo", "\"Acho que sim\" (muito dúbio)"), + ]; + + public IReadOnlyList Examples { get; } = + [ + new("Contraproposta de parcelas", + "Posso parcelar em 20x de R$100. Topa?", + "Bora em 48?", + "counter_proposal", + "48 é quantidade de parcelas, não R$48"), + new("Confirmação simples", + "Seu pedido ficou em R$250. Confirma?", + "Pode mandar", + "confirmation"), + new("Pergunta de volta", + "Quer agendar pra terça?", + "Que horas vocês atendem?", + "question"), + new("Número ambíguo em contexto de endereço", + "Confirma o endereço Rua das Flores, 100?", + "É 200", + "answer", + "200 é o número do endereço, não R$200"), + new("Quer falar com humano", + "Posso te ajudar a escolher o plano ideal.", + "Prefiro falar com alguém de verdade", + "handoff"), + new("Rejeição educada", + "Temos um plano premium por R$99/mês. Interesse?", + "Não, obrigado. Tá caro pra mim", + "rejection"), + new("Off-topic", + "Qual seu CPF para consulta?", + "Vocês vendem seguro de carro?", + "off_topic"), + new("Saudação sem conteúdo", + "Boa tarde! Em que posso ajudar?", + "Boa tarde!", + "greeting"), + ]; + + public void OnGet() { } +} diff --git a/src/Nalu.Web/Pages/ValidatorCard.cs b/src/Nalu.Web/Pages/ValidatorCard.cs new file mode 100644 index 0000000..f87641b --- /dev/null +++ b/src/Nalu.Web/Pages/ValidatorCard.cs @@ -0,0 +1,10 @@ +namespace Nalu.Web.Pages; + +public record ValidatorCard( + string Icon, + string Name, + string Description, + int Credits, + bool IsNew = false); + +public enum ValidatorCategory { Brazilian, Universal } diff --git a/src/Nalu.Web/Pages/_Layout.cshtml b/src/Nalu.Web/Pages/_Layout.cshtml new file mode 100644 index 0000000..6246ab2 --- /dev/null +++ b/src/Nalu.Web/Pages/_Layout.cshtml @@ -0,0 +1,208 @@ + + + + + + @ViewData["Title"] – NALU AI + + + + + + + @await RenderSectionAsync("Head", required: false) + + + + + + + @RenderBody() + + + + + @await RenderSectionAsync("Scripts", required: false) + + + + + + + + + diff --git a/src/Nalu.Web/Pages/_ViewImports.cshtml b/src/Nalu.Web/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..880e300 --- /dev/null +++ b/src/Nalu.Web/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using Nalu.Web.Pages +@using System.Security.Claims +@namespace Nalu.Web.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/Nalu.Web/Pages/_ViewStart.cshtml b/src/Nalu.Web/Pages/_ViewStart.cshtml new file mode 100644 index 0000000..820a2f6 --- /dev/null +++ b/src/Nalu.Web/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/src/Nalu.Web/PostProcessors/CalculateAge.cs b/src/Nalu.Web/PostProcessors/CalculateAge.cs new file mode 100644 index 0000000..467c54f --- /dev/null +++ b/src/Nalu.Web/PostProcessors/CalculateAge.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Nalu.Web.PostProcessors; + +/// Reads the JSON produced by ParseDate, adds "age" and "minor" fields. +/// Sets SuggestionKeyOverride = "when_minor" when age < 18. +public class CalculateAge : IPostProcessor +{ + public string Name => "calculate_age"; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return ProcessorResult.Invalid("Nenhuma data para calcular a idade"); + + JsonObject obj; + try + { + obj = JsonNode.Parse(value)?.AsObject() + ?? throw new InvalidOperationException(); + } + catch + { + return ProcessorResult.Invalid("Formato interno de data inválido"); + } + + if (!DateTime.TryParse(obj["date"]?.GetValue(), out var birth)) + return ProcessorResult.Invalid("Campo 'date' ausente ou inválido no JSON de data"); + + var age = ParseDate.ComputeAge(birth); + var minor = age < 18; + + obj["age"] = age; + obj["minor"] = minor; + + var json = obj.ToJsonString(); + + return minor + ? ProcessorResult.WithOverride(json, "when_minor") + : ProcessorResult.Ok(json); + } +} diff --git a/src/Nalu.Web/PostProcessors/CapitalizeProperName.cs b/src/Nalu.Web/PostProcessors/CapitalizeProperName.cs new file mode 100644 index 0000000..3ae8cf4 --- /dev/null +++ b/src/Nalu.Web/PostProcessors/CapitalizeProperName.cs @@ -0,0 +1,33 @@ +using System.Globalization; +using Nalu.Web.PostProcessors; + +namespace Nalu.Web.PostProcessors; + +public class CapitalizeProperName : IPostProcessor +{ + public string Name => "capitalize_proper_name"; + + private static readonly HashSet LowercaseWords = new(StringComparer.OrdinalIgnoreCase) + { + "de", "da", "do", "das", "dos", "e", "em", "na", "no", "nas", "nos", "van", "von", "del" + }; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Ok(value); + + var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var result = new List(words.Length); + + for (int i = 0; i < words.Length; i++) + { + var word = words[i]; + if (i > 0 && LowercaseWords.Contains(word)) + result.Add(word.ToLowerInvariant()); + else + result.Add(CultureInfo.InvariantCulture.TextInfo.ToTitleCase(word.ToLowerInvariant())); + } + + return ProcessorResult.Ok(string.Join(' ', result)); + } +} diff --git a/src/Nalu.Web/PostProcessors/CorrectEmailTypos.cs b/src/Nalu.Web/PostProcessors/CorrectEmailTypos.cs new file mode 100644 index 0000000..4198307 --- /dev/null +++ b/src/Nalu.Web/PostProcessors/CorrectEmailTypos.cs @@ -0,0 +1,57 @@ +namespace Nalu.Web.PostProcessors; + +public class CorrectEmailTypos : IPostProcessor +{ + public string Name => "correct_email_typos"; + + private static readonly Dictionary DomainFixes = new(StringComparer.OrdinalIgnoreCase) + { + // Gmail + { "gmail.com.br", "gmail.com" }, + { "gamil.com", "gmail.com" }, + { "gmaill.com", "gmail.com" }, + { "gnail.com", "gmail.com" }, + { "gmail.con", "gmail.com" }, + { "gmal.com", "gmail.com" }, + // Hotmail + { "hotmal.com", "hotmail.com" }, + { "hotmial.com", "hotmail.com" }, + { "hotmail.con", "hotmail.com" }, + { "homail.com", "hotmail.com" }, + { "htmail.com", "hotmail.com" }, + // Outlook + { "outlok.com", "outlook.com" }, + { "outllok.com", "outlook.com" }, + { "outlook.con", "outlook.com" }, + // Yahoo + { "yaho.com", "yahoo.com" }, + { "yahooo.com", "yahoo.com" }, + { "yahoo.con", "yahoo.com" }, + // iCloud + { "iclod.com", "icloud.com" }, + { "icould.com", "icloud.com" }, + // UOL/BOL + { "bol.com.be", "bol.com.br" }, + { "uol.com.be", "uol.com.br" }, + }; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Ok(value); + + var email = value.Trim().ToLowerInvariant(); + var atIdx = email.IndexOf('@'); + if (atIdx < 0) return ProcessorResult.Ok(email); + + var local = email[..atIdx]; + var domain = email[(atIdx + 1)..]; + + if (DomainFixes.TryGetValue(domain, out var corrected)) + { + var fixedEmail = $"{local}@{corrected}"; + return ProcessorResult.Corrected(fixedEmail, value.Trim()); + } + + return ProcessorResult.Ok(email); + } +} diff --git a/src/Nalu.Web/PostProcessors/FormatCep.cs b/src/Nalu.Web/PostProcessors/FormatCep.cs new file mode 100644 index 0000000..e3a20dd --- /dev/null +++ b/src/Nalu.Web/PostProcessors/FormatCep.cs @@ -0,0 +1,23 @@ +using System.Text.RegularExpressions; + +namespace Nalu.Web.PostProcessors; + +public class FormatCep : IPostProcessor +{ + public string Name => "format_cep"; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Invalid("CEP não informado"); + + var digits = Regex.Replace(value, @"\D", ""); + + if (digits.Length != 8) + return ProcessorResult.Invalid("CEP deve ter 8 dígitos"); + + if (digits == "00000000") + return ProcessorResult.Invalid("CEP inválido"); + + return ProcessorResult.Ok($"{digits[..5]}-{digits[5..]}"); + } +} diff --git a/src/Nalu.Web/PostProcessors/FormatPhone.cs b/src/Nalu.Web/PostProcessors/FormatPhone.cs new file mode 100644 index 0000000..9205e12 --- /dev/null +++ b/src/Nalu.Web/PostProcessors/FormatPhone.cs @@ -0,0 +1,69 @@ +using System.Text.RegularExpressions; + +namespace Nalu.Web.PostProcessors; + +public class FormatPhone : IPostProcessor +{ + public string Name => "format_phone"; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Invalid("Telefone não informado"); + + var digits = Regex.Replace(value, @"\D", ""); + + // Strip Brazilian country code +55 + if (digits.StartsWith("55") && digits.Length is 12 or 13) + digits = digits[2..]; + + return digits.Length switch + { + 11 => FormatMobile(digits), + 10 => FormatLandline(digits), + _ => ProcessorResult.Invalid("Telefone inválido (esperado 10 ou 11 dígitos com DDD)") + }; + } + + private static ProcessorResult FormatMobile(string digits) + { + var ddd = digits[..2]; + if (!IsValidDdd(ddd)) return ProcessorResult.Invalid($"DDD inválido: {ddd}"); + if (digits[2] != '9') return ProcessorResult.Invalid("Celular deve começar com 9 após o DDD"); + return ProcessorResult.Ok($"({ddd}) {digits[2..7]}-{digits[7..]}"); + } + + private static ProcessorResult FormatLandline(string digits) + { + var ddd = digits[..2]; + if (!IsValidDdd(ddd)) return ProcessorResult.Invalid($"DDD inválido: {ddd}"); + return ProcessorResult.Ok($"({ddd}) {digits[2..6]}-{digits[6..]}"); + } + + // Complete list of valid Brazilian DDDs per ANATEL (Plano de Numeração) + // Source: anatel.gov.br/outorga/plano-de-numeracao + private static readonly HashSet ValidDdds = + [ + // São Paulo + "11","12","13","14","15","16","17","18","19", + // Rio de Janeiro, Espírito Santo + "21","22","24","27","28", + // Minas Gerais + "31","32","33","34","35","37","38", + // Paraná + "41","42","43","44","45","46", + // Santa Catarina + "47","48","49", + // Rio Grande do Sul + "51","53","54","55", + // Distrito Federal / Goiás / Tocantins / MT / MS / AC / RO + "61","62","63","64","65","66","67","68","69", + // Bahia, Sergipe + "71","73","74","75","77","79", + // PE, AL, PB, RN, CE, PI + "81","82","83","84","85","86","87","88","89", + // PA, AM, RR, AP, MA, RO (norte) + "91","92","93","94","95","96","97","98","99", + ]; + + private static bool IsValidDdd(string ddd) => ValidDdds.Contains(ddd); +} diff --git a/src/Nalu.Web/PostProcessors/FormatPlate.cs b/src/Nalu.Web/PostProcessors/FormatPlate.cs new file mode 100644 index 0000000..d3bfa5f --- /dev/null +++ b/src/Nalu.Web/PostProcessors/FormatPlate.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Nalu.Web.PostProcessors; + +/// Normalizes Brazilian vehicle plates (Mercosul and old format). +/// Returns JSON: {"plate":"ABC1D23","format":"mercosul"} or {"plate":"ABC1234","format":"old"}. +public class FormatPlate : IPostProcessor +{ + public string Name => "format_plate"; + + // Mercosul: AAA1B23 + private static readonly Regex Mercosul = new(@"^([A-Z]{3})(\d)([A-Z])(\d{2})$", RegexOptions.Compiled); + // Old: AAA1234 + private static readonly Regex Old = new(@"^([A-Z]{3})(\d{4})$", RegexOptions.Compiled); + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return ProcessorResult.Invalid("Placa não informada"); + + // Normalize: uppercase, strip non-alphanumeric + var normalized = Regex.Replace(value.ToUpperInvariant(), @"[^A-Z0-9]", ""); + + if (Mercosul.IsMatch(normalized)) + { + var json = JsonSerializer.Serialize(new { plate = normalized, format = "mercosul" }); + return ProcessorResult.Ok(json); + } + + if (Old.IsMatch(normalized)) + { + var json = JsonSerializer.Serialize(new { plate = normalized, format = "old" }); + return ProcessorResult.Ok(json); + } + + return ProcessorResult.Invalid($"Placa inválida: '{normalized}'. Use ABC1D23 (Mercosul) ou ABC1234 (antiga)."); + } +} diff --git a/src/Nalu.Web/PostProcessors/IPostProcessor.cs b/src/Nalu.Web/PostProcessors/IPostProcessor.cs new file mode 100644 index 0000000..e75fa22 --- /dev/null +++ b/src/Nalu.Web/PostProcessors/IPostProcessor.cs @@ -0,0 +1,26 @@ +namespace Nalu.Web.PostProcessors; + +public record ProcessorResult +{ + public string? Value { get; init; } + public bool IsValid { get; init; } = true; + public bool WasCorrected { get; init; } + public string? OriginalValue { get; init; } + public string? InvalidReason { get; init; } + + /// Overrides the suggestion template key selected by SuggestionBuilder (e.g. "when_minor"). + public string? SuggestionKeyOverride { get; init; } + + public static ProcessorResult Ok(string? value) => new() { Value = value }; + public static ProcessorResult Invalid(string? reason = null) => new() { IsValid = false, InvalidReason = reason }; + public static ProcessorResult Corrected(string value, string original) => + new() { Value = value, WasCorrected = true, OriginalValue = original }; + public static ProcessorResult WithOverride(string value, string suggestionKey) => + new() { Value = value, SuggestionKeyOverride = suggestionKey }; +} + +public interface IPostProcessor +{ + string Name { get; } + ProcessorResult Process(string? value); +} diff --git a/src/Nalu.Web/PostProcessors/NormalizePostalCode.cs b/src/Nalu.Web/PostProcessors/NormalizePostalCode.cs new file mode 100644 index 0000000..99b33c0 --- /dev/null +++ b/src/Nalu.Web/PostProcessors/NormalizePostalCode.cs @@ -0,0 +1,31 @@ +using System.Text.RegularExpressions; + +namespace Nalu.Web.PostProcessors; + +public class NormalizePostalCode : IPostProcessor +{ + public string Name => "normalize_postal_code"; + + // Accepts: 3–10 alphanumeric chars, optionally split by a single space or hyphen + private static readonly Regex ValidPattern = + new(@"^[A-Z0-9]{2,}([\s\-][A-Z0-9]{2,})?$", RegexOptions.Compiled); + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return ProcessorResult.Invalid("Código postal vazio"); + + // Uppercase + collapse whitespace + var normalized = Regex.Replace(value.Trim().ToUpperInvariant(), @"\s+", " "); + + // Reject all-zeros + if (Regex.IsMatch(normalized, @"^0+$")) + return ProcessorResult.Invalid("Código postal inválido (todos zeros)"); + + // Must contain at least one digit or letter group matching postal pattern + if (!ValidPattern.IsMatch(normalized)) + return ProcessorResult.Invalid($"Formato de código postal não reconhecido: {normalized}"); + + return ProcessorResult.Ok(normalized); + } +} diff --git a/src/Nalu.Web/PostProcessors/ParseDate.cs b/src/Nalu.Web/PostProcessors/ParseDate.cs new file mode 100644 index 0000000..636245b --- /dev/null +++ b/src/Nalu.Web/PostProcessors/ParseDate.cs @@ -0,0 +1,115 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Nalu.Web.PostProcessors; + +/// Parses a date string in multiple formats and returns JSON: {"date":"YYYY-MM-DD","formatted":"DD/MM/YYYY"}. +/// Validates: not in future, not > 130 years ago, real calendar date. +public class ParseDate : IPostProcessor +{ + public string Name => "parse_date"; + + private static readonly string[] Formats = + [ + "yyyy-MM-dd", // ISO + "dd/MM/yyyy", "dd-MM-yyyy", "dd.MM.yyyy", + "MM/dd/yyyy", "MM-dd-yyyy", // en-US (tried after DD/MM) + "dd/MM/yy", "MM/dd/yy", + "d/M/yyyy", "d-M-yyyy", + "d/M/yy" + ]; + + // Month name tables + private static readonly Dictionary MonthsPt = new(StringComparer.OrdinalIgnoreCase) + { + {"janeiro",1},{"fevereiro",2},{"março",3},{"marco",3},{"abril",4}, + {"maio",5},{"junho",6},{"julho",7},{"agosto",8},{"setembro",9}, + {"outubro",10},{"novembro",11},{"dezembro",12} + }; + private static readonly Dictionary MonthsEn = new(StringComparer.OrdinalIgnoreCase) + { + {"january",1},{"february",2},{"march",3},{"april",4},{"may",5}, + {"june",6},{"july",7},{"august",8},{"september",9},{"october",10}, + {"november",11},{"december",12}, + {"jan",1},{"feb",2},{"mar",3},{"apr",4},{"jun",6},{"jul",7}, + {"aug",8},{"sep",9},{"oct",10},{"nov",11},{"dec",12} + }; + private static readonly Dictionary MonthsEs = new(StringComparer.OrdinalIgnoreCase) + { + {"enero",1},{"febrero",2},{"marzo",3},{"abril",4},{"mayo",5}, + {"junio",6},{"julio",7},{"agosto",8},{"septiembre",9}, + {"octubre",10},{"noviembre",11},{"diciembre",12} + }; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return ProcessorResult.Invalid("Data não informada"); + + var date = TryParseAll(value.Trim()); + if (date is null) + return ProcessorResult.Invalid($"Formato de data não reconhecido: {value}"); + + if (date.Value > DateTime.Today) + return ProcessorResult.Invalid("Data de nascimento não pode ser no futuro"); + + var age = ComputeAge(date.Value); + if (age > 130) + return ProcessorResult.Invalid("Data de nascimento improvável (mais de 130 anos atrás)"); + + var json = JsonSerializer.Serialize(new + { + date = date.Value.ToString("yyyy-MM-dd"), + formatted = date.Value.ToString("dd/MM/yyyy") + }); + + return ProcessorResult.Ok(json); + } + + private static DateTime? TryParseAll(string input) + { + // 1. Try standard formats + if (DateTime.TryParseExact(input, Formats, CultureInfo.InvariantCulture, + DateTimeStyles.None, out var dt)) + return dt; + + // 2. Try written month — pt: "15 de março de 1990" + var mPt = Regex.Match(input, + @"(\d{1,2})\s+de\s+(\w+)\s+de\s+(\d{2,4})", RegexOptions.IgnoreCase); + if (mPt.Success && MonthsPt.TryGetValue(mPt.Groups[2].Value, out var moPt)) + return BuildDate(int.Parse(mPt.Groups[1].Value), moPt, ExpandYear(int.Parse(mPt.Groups[3].Value))); + + // 3. en: "March 15, 1990" / "March 15 1990" + var mEn = Regex.Match(input, + @"(\w+)\s+(\d{1,2})[,\s]+(\d{2,4})", RegexOptions.IgnoreCase); + if (mEn.Success && MonthsEn.TryGetValue(mEn.Groups[1].Value, out var moEn)) + return BuildDate(int.Parse(mEn.Groups[2].Value), moEn, ExpandYear(int.Parse(mEn.Groups[3].Value))); + + // 4. es: "15 de marzo de 1990" + var mEs = Regex.Match(input, + @"(\d{1,2})\s+de\s+(\w+)\s+de\s+(\d{2,4})", RegexOptions.IgnoreCase); + if (mEs.Success && MonthsEs.TryGetValue(mEs.Groups[2].Value, out var moEs)) + return BuildDate(int.Parse(mEs.Groups[1].Value), moEs, ExpandYear(int.Parse(mEs.Groups[3].Value))); + + return null; + } + + private static DateTime? BuildDate(int day, int month, int year) + { + if (month < 1 || month > 12 || day < 1) return null; + if (day > DateTime.DaysInMonth(year, month)) return null; + return new DateTime(year, month, day); + } + + private static int ExpandYear(int y) => + y < 100 ? (y > 30 ? 1900 + y : 2000 + y) : y; + + public static int ComputeAge(DateTime birth) + { + var today = DateTime.Today; + var age = today.Year - birth.Year; + if (birth.Date > today.AddYears(-age)) age--; + return age; + } +} diff --git a/src/Nalu.Web/PostProcessors/RemoveTitles.cs b/src/Nalu.Web/PostProcessors/RemoveTitles.cs new file mode 100644 index 0000000..032f09f --- /dev/null +++ b/src/Nalu.Web/PostProcessors/RemoveTitles.cs @@ -0,0 +1,28 @@ +using Nalu.Web.PostProcessors; + +namespace Nalu.Web.PostProcessors; + +public class RemoveTitles : IPostProcessor +{ + public string Name => "remove_titles"; + + private static readonly HashSet Titles = new(StringComparer.OrdinalIgnoreCase) + { + "Dr.", "Dr", "Dra.", "Dra", "Sr.", "Sr", "Sra.", "Sra", + "Prof.", "Prof", "Profa.", "Profa", "Eng.", "Eng", + "Doutor", "Doutora", "Senhor", "Senhora", "Me.", "Me", "Msc" + }; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Ok(value); + + var words = value + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Where(w => !Titles.Contains(w)) + .ToArray(); + + var result = string.Join(' ', words); + return ProcessorResult.Ok(string.IsNullOrWhiteSpace(result) ? value : result); + } +} diff --git a/src/Nalu.Web/PostProcessors/SelectCancelSuggestion.cs b/src/Nalu.Web/PostProcessors/SelectCancelSuggestion.cs new file mode 100644 index 0000000..5d103c1 --- /dev/null +++ b/src/Nalu.Web/PostProcessors/SelectCancelSuggestion.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Nodes; + +namespace Nalu.Web.PostProcessors; + +/// Reads the cancel-intent JSON and sets SuggestionKeyOverride based on cancel_type + is_threat. +public class SelectCancelSuggestion : IPostProcessor +{ + public string Name => "select_cancel_suggestion"; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Ok(value); + + try + { + var obj = JsonNode.Parse(value)?.AsObject(); + if (obj is null) return ProcessorResult.Ok(value); + + var cancelType = obj["cancel_type"]?.GetValue() ?? "none"; + var isThreat = obj["is_threat"]?.GetValue() ?? false; + + var key = cancelType switch + { + "service" => isThreat ? "when_threat" : "when_cancel_service", + "operation" => "when_cancel_operation", + _ => "when_not_cancel" + }; + + return ProcessorResult.WithOverride(value, key); + } + catch + { + return ProcessorResult.Ok(value); + } + } +} diff --git a/src/Nalu.Web/PostProcessors/SelectHandoffSuggestion.cs b/src/Nalu.Web/PostProcessors/SelectHandoffSuggestion.cs new file mode 100644 index 0000000..0ee354c --- /dev/null +++ b/src/Nalu.Web/PostProcessors/SelectHandoffSuggestion.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Nodes; + +namespace Nalu.Web.PostProcessors; + +/// Reads the handoff JSON and sets SuggestionKeyOverride based on wants_human + urgency. +public class SelectHandoffSuggestion : IPostProcessor +{ + public string Name => "select_handoff_suggestion"; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Ok(value); + + try + { + var obj = JsonNode.Parse(value)?.AsObject(); + if (obj is null) return ProcessorResult.Ok(value); + + var wantsHuman = obj["wants_human"]?.GetValue() ?? false; + if (!wantsHuman) + return ProcessorResult.WithOverride(value, "when_not_handoff"); + + var urgency = obj["urgency"]?.GetValue() ?? "medium"; + var key = urgency switch + { + "high" => "when_handoff_high", + "low" => "when_handoff_low", + _ => "when_handoff_medium" + }; + + return ProcessorResult.WithOverride(value, key); + } + catch + { + return ProcessorResult.Ok(value); + } + } +} diff --git a/src/Nalu.Web/PostProcessors/ValidateCnpjDigit.cs b/src/Nalu.Web/PostProcessors/ValidateCnpjDigit.cs new file mode 100644 index 0000000..ca1c473 --- /dev/null +++ b/src/Nalu.Web/PostProcessors/ValidateCnpjDigit.cs @@ -0,0 +1,49 @@ +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. +public class ValidateCnpjDigit : IPostProcessor +{ + public string Name => "validate_cnpj_digit"; + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return ProcessorResult.Invalid("CNPJ não informado"); + + var digits = Regex.Replace(value, @"\D", ""); + + if (digits.Length != 14) + return ProcessorResult.Invalid($"CNPJ deve ter 14 dígitos (encontrado: {digits.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)"); + + if (!CheckDigits(digits)) + 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..]}"; + return ProcessorResult.Ok(formatted); + } + + private static bool CheckDigits(string d) + { + // First check digit (position 12) + 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 r1 = sum1 % 11; + var cd1 = r1 < 2 ? 0 : 11 - r1; + if (d[12] - '0' != cd1) return false; + + // Second check digit (position 13) + 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 r2 = sum2 % 11; + var cd2 = r2 < 2 ? 0 : 11 - r2; + return d[13] - '0' == cd2; + } +} diff --git a/src/Nalu.Web/PostProcessors/ValidateCpfDigit.cs b/src/Nalu.Web/PostProcessors/ValidateCpfDigit.cs new file mode 100644 index 0000000..f25167f --- /dev/null +++ b/src/Nalu.Web/PostProcessors/ValidateCpfDigit.cs @@ -0,0 +1,47 @@ +using System.Text.RegularExpressions; + +namespace Nalu.Web.PostProcessors; + +public class ValidateCpfDigit : IPostProcessor +{ + public string Name => "validate_cpf_digit"; + + private static readonly string[] AllSameDigit = + Enumerable.Range(0, 10).Select(i => new string((char)('0' + i), 11)).ToArray(); + + public ProcessorResult Process(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Invalid("CPF não informado"); + + var digits = Regex.Replace(value, @"\D", ""); + + if (digits.Length != 11) + return ProcessorResult.Invalid("CPF deve ter 11 dígitos"); + + if (Array.IndexOf(AllSameDigit, digits) >= 0) + return ProcessorResult.Invalid("CPF inválido (dígitos repetidos)"); + + if (!ValidateMod11(digits)) + return ProcessorResult.Invalid("CPF inválido (dígitos verificadores incorretos)"); + + var formatted = $"{digits[..3]}.{digits[3..6]}.{digits[6..9]}-{digits[9..]}"; + return ProcessorResult.Ok(formatted); + } + + private static bool ValidateMod11(string digits) + { + // First check digit + int sum = 0; + for (int i = 0; i < 9; i++) sum += (digits[i] - '0') * (10 - i); + int rem = sum % 11; + int d1 = rem < 2 ? 0 : 11 - rem; + if (d1 != digits[9] - '0') return false; + + // Second check digit + sum = 0; + for (int i = 0; i < 10; i++) sum += (digits[i] - '0') * (11 - i); + rem = sum % 11; + int d2 = rem < 2 ? 0 : 11 - rem; + return d2 == digits[10] - '0'; + } +} diff --git a/src/Nalu.Web/Program.cs b/src/Nalu.Web/Program.cs new file mode 100644 index 0000000..86d4e04 --- /dev/null +++ b/src/Nalu.Web/Program.cs @@ -0,0 +1,266 @@ +using AspNet.Security.OAuth.GitHub; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Google; +using Microsoft.AspNetCore.Authentication.MicrosoftAccount; +using MongoDB.Driver; +using Nalu.Web.Data; +using Nalu.Web.Data.Repositories; +using Nalu.Web.Endpoints; +using Stripe; +using Nalu.Web.Enrichers; +using Nalu.Web.Infrastructure; +using Nalu.Web.Mcp; +using Nalu.Web.PostProcessors; +using Nalu.Web.Services; +using Nalu.Web.Services.LlmRouter; +using Scalar.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +// ── Razor Pages (site) ─────────────────────────────────────────────────────── +builder.Services.AddRazorPages(); + +// ── OpenAPI / Scalar ────────────────────────────────────────────────────────── +builder.Services.AddOpenApi("v1", options => +{ + options.AddDocumentTransformer((doc, _, _) => + { + doc.Info.Title = "NALU AI API"; + doc.Info.Version = "v1"; + doc.Info.Description = "Natural Language Understanding — extrai intenções reais de diálogos agente/usuário."; + return Task.CompletedTask; + }); +}); + +// ── MongoDB ─────────────────────────────────────────────────────────────────── +builder.Services.AddSingleton(sp => +{ + var connStr = builder.Configuration.GetConnectionString("MongoDB") ?? string.Empty; + if (string.IsNullOrWhiteSpace(connStr)) return null!; + + var settings = MongoClientSettings.FromConnectionString( + connStr + (connStr.Contains('?') ? "&" : "?") + + "maxPoolSize=200&minPoolSize=10&maxIdleTimeMS=30000"); + + return new MongoClient(settings); +}); + +builder.Services.AddSingleton(); + +// ── Repositories ────────────────────────────────────────────────────────────── +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// ── Authentication ─────────────────────────────────────────────────────────── +// Site = Cookie (Razor Pages). API = Bearer API key (/v1/*). DO NOT mix. +builder.Services.AddAuthentication(options => +{ + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; +}) +.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => +{ + options.LoginPath = "/login"; + options.LogoutPath = "/auth/logout"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.ExpireTimeSpan = TimeSpan.FromDays(30); + options.SlidingExpiration = true; +}) +// Temporary cookie for external OAuth handshake +.AddCookie("ExternalCookie", options => +{ + options.Cookie.Name = "nalu.external"; + options.ExpireTimeSpan = TimeSpan.FromMinutes(10); +}) +// API key scheme — used explicitly by /v1/* endpoints +.AddScheme(ApiKeyAuthScheme.Name, null) +.AddGoogle(GoogleDefaults.AuthenticationScheme, options => +{ + options.ClientId = builder.Configuration["OAuth:Google:ClientId"] ?? string.Empty; + options.ClientSecret = builder.Configuration["OAuth:Google:ClientSecret"] ?? string.Empty; + options.CallbackPath = "/signin-google"; + options.SignInScheme = "ExternalCookie"; + options.SaveTokens = false; +}) +.AddMicrosoftAccount(MicrosoftAccountDefaults.AuthenticationScheme, options => +{ + options.ClientId = builder.Configuration["OAuth:Microsoft:ClientId"] ?? string.Empty; + options.ClientSecret = builder.Configuration["OAuth:Microsoft:ClientSecret"] ?? string.Empty; + options.CallbackPath = "/signin-microsoft"; + options.SignInScheme = "ExternalCookie"; + options.SaveTokens = false; +}) +.AddGitHub(GitHubAuthenticationDefaults.AuthenticationScheme, options => +{ + options.ClientId = builder.Configuration["OAuth:GitHub:ClientId"] ?? string.Empty; + options.ClientSecret = builder.Configuration["OAuth:GitHub:ClientSecret"] ?? string.Empty; + options.CallbackPath = "/signin-github"; + options.SignInScheme = "ExternalCookie"; + options.Scope.Add("user:email"); + options.SaveTokens = false; +}); + +builder.Services.AddAuthorization(options => +{ + // API endpoints — must present a valid Bearer API key + options.AddPolicy("ApiKey", policy => + policy.AddAuthenticationSchemes(ApiKeyAuthScheme.Name) + .RequireAuthenticatedUser()); +}); + +// ── Session (required for OAuth state) ─────────────────────────────────────── +builder.Services.AddDistributedMemoryCache(); +builder.Services.AddSession(options => +{ + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + options.IdleTimeout = TimeSpan.FromMinutes(10); +}); + +// ── Caching ────────────────────────────────────────────────────────────────── +builder.Services.AddMemoryCache(); + +// ── HTTP clients ───────────────────────────────────────────────────────────── +builder.Services.AddHttpClient(client => +{ + var baseUrl = builder.Configuration["Groq:BaseUrl"] ?? "https://api.groq.com/openai/v1"; + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + '/'); + client.DefaultRequestHeaders.Add( + "Authorization", + $"Bearer {builder.Configuration["Groq:ApiKey"]}"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +// ── LLM Router (Groq → OpenRouter → Google AI) ─────────────────────────────── +builder.Services.AddHttpClient(client => +{ + var baseUrl = builder.Configuration["Groq:BaseUrl"] ?? "https://api.groq.com/openai/v1"; + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + '/'); + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {builder.Configuration["Groq:ApiKey"]}"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +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("X-Title", "NALU AI"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +builder.Services.AddHttpClient(client => +{ + var baseUrl = builder.Configuration["GoogleAi:BaseUrl"] ?? "https://generativelanguage.googleapis.com/v1beta/openai/"; + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + '/'); + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {builder.Configuration["GoogleAi:ApiKey"]}"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +builder.Services.AddSingleton(sp => +{ + var providers = new List + { + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() + }; + return new LlmRouter(providers, sp.GetRequiredService>()); +}); + +// ViaCEP enricher — no fixed base address (calls multiple URLs) +builder.Services.AddHttpClient(client => +{ + client.Timeout = TimeSpan.FromSeconds(10); + client.DefaultRequestHeaders.Add("User-Agent", "nalu-ai/1.0"); +}); +builder.Services.AddTransient(sp => sp.GetRequiredService()); + +// ── Post-processors ─────────────────────────────────────────────────────────── +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// ── Domain services ────────────────────────────────────────────────────────── +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +// EnrichmentService is Scoped because ViaCepEnricher is Transient (HttpClient lifecycle) +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// ── MCP server ──────────────────────────────────────────────────────────────── +builder.Services.AddSingleton(); + +// ── App ─────────────────────────────────────────────────────────────────────── +// ── Stripe ──────────────────────────────────────────────────────────────────── +StripeConfiguration.ApiKey = builder.Configuration["Stripe:SecretKey"]; + +var app = builder.Build(); + +// Initialize MongoDB indexes on startup +var mongo = app.Services.GetRequiredService(); +await mongo.InitializeAsync(); + +app.UseStaticFiles(); +app.UseSession(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapOpenApi(); +app.MapScalarApiReference(options => +{ + options.Title = "NALU AI"; + options.DefaultHttpClient = new(ScalarTarget.Http, ScalarClient.HttpClient); +}); + +app.MapRazorPages(); +app.MapExtractEndpoints(); +app.MapValidatorsEndpoints(); +app.MapPlaygroundEndpoints(); + +// ── Health check ────────────────────────────────────────────────────────────── +app.MapGet("/health", () => Results.Ok(new { status = "ok", ts = DateTime.UtcNow })) + .WithTags("Health") + .WithSummary("Health check") + .AllowAnonymous(); + +// ── MCP endpoint ────────────────────────────────────────────────────────────── +app.MapPost("/mcp", async (HttpContext ctx, McpServer mcp, CancellationToken ct) => + await mcp.HandleAsync(ctx, ct)) + .RequireAuthorization("ApiKey") + .WithName("McpEndpoint") + .WithSummary("MCP Server (JSON-RPC 2.0 / Streamable HTTP)") + .WithTags("MCP") + .WithOpenApi(); + +app.Run(); + +// Exposed for WebApplicationFactory in integration tests +public partial class Program { } diff --git a/src/Nalu.Web/Properties/launchSettings.json b/src/Nalu.Web/Properties/launchSettings.json new file mode 100644 index 0000000..2c5c9ec --- /dev/null +++ b/src/Nalu.Web/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "", + "applicationUrl": "http://localhost:5282", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "", + "applicationUrl": "https://localhost:7282;http://localhost:5282", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Nalu.Web/Services/AuthService.cs b/src/Nalu.Web/Services/AuthService.cs new file mode 100644 index 0000000..651250e --- /dev/null +++ b/src/Nalu.Web/Services/AuthService.cs @@ -0,0 +1,31 @@ +using Nalu.Web.Data.Models; +using Nalu.Web.Data.Repositories; +using Nalu.Web.Infrastructure; + +namespace Nalu.Web.Services; + +/// +/// Validates API keys. Checks MongoDB first, falls back to config for test/bootstrap keys. +/// +public class AuthService(ApiKeyRepository apiKeyRepo, IConfiguration config) +{ + private readonly List _configKeys = + config.GetSection("ApiKeys").Get>() ?? []; + + public async Task<(string Plan, string Owner)?> ValidateAsync(string? key, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(key)) return null; + + var dbKey = await apiKeyRepo.FindAsync(key, ct); + if (dbKey is not null) return (dbKey.Plan, dbKey.Owner); + + var cfgKey = _configKeys.FirstOrDefault(k => k.Key == key); + return cfgKey is not null ? (cfgKey.Plan, cfgKey.Owner) : null; + } + + public string? GetPlan(string key) + { + var cfgKey = _configKeys.FirstOrDefault(k => k.Key == key); + return cfgKey?.Plan; + } +} diff --git a/src/Nalu.Web/Services/CacheService.cs b/src/Nalu.Web/Services/CacheService.cs new file mode 100644 index 0000000..8b336b8 --- /dev/null +++ b/src/Nalu.Web/Services/CacheService.cs @@ -0,0 +1,38 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Caching.Memory; +using Nalu.Web.Models; + +namespace Nalu.Web.Services; + +public class CacheService +{ + private readonly IMemoryCache _cache; + private readonly IConfiguration _config; + + public CacheService(IMemoryCache cache, IConfiguration config) + { + _cache = cache; + _config = config; + } + + public bool TryGet(string validatorId, ExtractionRequest request, out ExtractionResponse? response) + { + var key = ComputeKey(validatorId, request); + return _cache.TryGetValue(key, out response); + } + + public void Set(string validatorId, ExtractionRequest request, ExtractionResponse response) + { + var ttl = _config.GetValue("Cache:DefaultTtlMinutes", 60); + var key = ComputeKey(validatorId, request); + _cache.Set(key, response, TimeSpan.FromMinutes(ttl)); + } + + private static string ComputeKey(string validatorId, ExtractionRequest request) + { + var raw = $"{validatorId}|{request.AgentInput}|{request.UserInput}|{request.AgentContext}|{request.Language}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); + return Convert.ToHexString(hash); + } +} diff --git a/src/Nalu.Web/Services/CreditCosts.cs b/src/Nalu.Web/Services/CreditCosts.cs new file mode 100644 index 0000000..6075a89 --- /dev/null +++ b/src/Nalu.Web/Services/CreditCosts.cs @@ -0,0 +1,54 @@ +namespace Nalu.Web.Services; + +/// +/// Credit cost per validator. +/// 3 = all standard validators (deterministic + LLM extraction + smart suggestion included). +/// 5 = validate_reply (always LLM, full agent+user pair analysis). +/// +public static class CreditCosts +{ + private static readonly Dictionary _costs = new(StringComparer.OrdinalIgnoreCase) + { + // 3 credits — standard (includes smart contextual re-ask on failure) + ["validate_cpf"] = 3, + ["validate_cep"] = 3, + ["validate_cnpj"] = 3, + ["validate_email"] = 3, + ["validate_phone_br"] = 3, + ["validate_plate_br"] = 3, + ["validate_postal_code"] = 3, + ["validate_full_name"] = 3, + ["validate_yes_no"] = 3, + ["validate_birthdate"] = 3, + ["validate_handoff"] = 3, + ["validate_cancel_intent"] = 3, + ["validate_company_name"] = 3, + + // 5 credits — validate_reply (always LLM, agent+user context analysis) + ["validate_reply"] = 5, + }; + + public static int Get(string validatorId) => + _costs.TryGetValue(validatorId, out var cost) ? cost : 3; + + private static readonly Dictionary _aliases = new(StringComparer.OrdinalIgnoreCase) + { + ["name"] = "validate_full_name", + ["cpf"] = "validate_cpf", + ["cep"] = "validate_cep", + ["phone-br"] = "validate_phone_br", + ["email"] = "validate_email", + ["postal-code"] = "validate_postal_code", + ["yes-no"] = "validate_yes_no", + ["birthdate"] = "validate_birthdate", + ["handoff"] = "validate_handoff", + ["cancel-intent"] = "validate_cancel_intent", + ["cnpj"] = "validate_cnpj", + ["plate-br"] = "validate_plate_br", + ["company-name"] = "validate_company_name", + ["reply"] = "validate_reply", + }; + + public static int GetByEndpoint(string endpoint) => + _aliases.TryGetValue(endpoint, out var id) ? Get(id) : 3; +} diff --git a/src/Nalu.Web/Services/CreditService.cs b/src/Nalu.Web/Services/CreditService.cs new file mode 100644 index 0000000..07ac506 --- /dev/null +++ b/src/Nalu.Web/Services/CreditService.cs @@ -0,0 +1,241 @@ +using System.Security.Claims; +using Microsoft.Extensions.Caching.Memory; +using MongoDB.Driver; +using Nalu.Web.Data; +using Nalu.Web.Data.Models; + +namespace Nalu.Web.Services; + +public record CreditConsumeResult +{ + public bool Success { get; init; } + public int CreditsConsumed { get; init; } + public int CreditsUsed { get; init; } + public int CreditsLimit { get; init; } + public DateTime ResetAt { get; init; } + public object? ErrorPayload { get; init; } + + public int CreditsRemaining => Math.Max(0, CreditsLimit - CreditsUsed); +} + +public class CreditService(MongoDbContext db, IConfiguration config, IMemoryCache cache) +{ + public async Task TryConsumeAsync( + ClaimsPrincipal user, string validatorId, HttpContext ctx, CancellationToken ct = default) + { + var apiKey = user.FindFirst("api_key")?.Value ?? ""; + var plan = user.FindFirst("plan")?.Value ?? "free"; + var cost = CreditCosts.Get(validatorId); + + var monthlyLimit = GetPlanLimit(plan, "credits_per_month"); + var dailyLimit = GetPlanLimit(plan, "credits_per_day"); + var now = DateTime.UtcNow; + var yearMonth = now.ToString("yyyy-MM"); + var today = now.ToString("yyyy-MM-dd"); + var resetAt = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(1); + var dailyResetAt = now.Date.AddDays(1); + var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + + // ── Per-IP rate limit (all plans) ──────────────────────────────────── + var ipRateResult = CheckIpRateLimit(ip, cost); + if (!ipRateResult.allowed) + { + return new CreditConsumeResult + { + Success = false, CreditsConsumed = 0, + CreditsUsed = 0, CreditsLimit = monthlyLimit, ResetAt = resetAt, + ErrorPayload = new + { + error = "rate_limit_exceeded", + message = ipRateResult.isHour + ? "Muitas requisições por hora deste IP. Aguarde antes de tentar novamente." + : "Muitas requisições por minuto deste IP. Aguarde alguns segundos.", + retry_after_seconds = ipRateResult.isHour ? 3600 : 60, + } + }; + } + + // ── Shared-IP detection (free plan only) ───────────────────────────── + if (plan == "free" && !string.IsNullOrEmpty(apiKey)) + TrackIpKeyUsage(ip, apiKey, today); + + // ── Daily cap (free plan) ───────────────────────────────────────────── + if (dailyLimit > 0) + { + var dailyKey = $"daily:{apiKey}:{today}"; + var dailyUsed = cache.GetOrCreate(dailyKey, e => + { + e.AbsoluteExpiration = dailyResetAt; + return 0; + }); + + if (dailyUsed + cost > dailyLimit) + { + return new CreditConsumeResult + { + Success = false, CreditsConsumed = 0, + CreditsUsed = dailyUsed, CreditsLimit = dailyLimit, ResetAt = dailyResetAt, + ErrorPayload = Build429Body(plan, dailyUsed, dailyLimit, dailyResetAt, + isDailyLimit: true) + }; + } + + cache.Set(dailyKey, dailyUsed + cost, new MemoryCacheEntryOptions + { + AbsoluteExpiration = dailyResetAt + }); + } + + if (!db.IsConnected) + { + return new CreditConsumeResult + { + Success = true, CreditsConsumed = cost, CreditsUsed = cost, + CreditsLimit = monthlyLimit, ResetAt = resetAt + }; + } + + // ── Monthly limit ───────────────────────────────────────────────────── + var current = await db.UsageMonthly + .Find(u => u.ApiKey == apiKey && u.YearMonth == yearMonth) + .FirstOrDefaultAsync(ct); + + var currentTotal = current?.TotalCreditsUsed ?? 0; + + if (monthlyLimit > 0 && currentTotal + cost > monthlyLimit) + { + return new CreditConsumeResult + { + Success = false, CreditsConsumed = 0, + CreditsUsed = currentTotal, CreditsLimit = monthlyLimit, ResetAt = resetAt, + ErrorPayload = Build429Body(plan, currentTotal, monthlyLimit, resetAt) + }; + } + + // ── Atomic increment ────────────────────────────────────────────────── + var filter = Builders.Filter.And( + Builders.Filter.Eq(u => u.ApiKey, apiKey), + Builders.Filter.Eq(u => u.YearMonth, yearMonth)); + + var update = Builders.Update + .Inc(u => u.TotalCreditsUsed, cost) + .Inc(u => u.TotalRequests, 1) + .Inc($"credits_by_validator.{validatorId}", cost) + .Inc($"requests_by_validator.{validatorId}", 1) + .Set(u => u.UpdatedAt, now) + .SetOnInsert(u => u.Plan, plan) + .SetOnInsert(u => u.ApiKey, apiKey) + .SetOnInsert(u => u.YearMonth, yearMonth); + + var opts = new FindOneAndUpdateOptions + { + IsUpsert = true, + ReturnDocument = ReturnDocument.After + }; + + var after = await db.UsageMonthly.FindOneAndUpdateAsync(filter, update, opts, ct); + + return new CreditConsumeResult + { + Success = true, CreditsConsumed = cost, + CreditsUsed = after.TotalCreditsUsed, + CreditsLimit = monthlyLimit, ResetAt = resetAt + }; + } + + public void ApplyHeaders(HttpContext ctx, CreditConsumeResult result) + { + ctx.Response.Headers["X-Credits-Used"] = result.CreditsConsumed.ToString(); + ctx.Response.Headers["X-Credits-Remaining"] = result.CreditsRemaining.ToString(); + ctx.Response.Headers["X-Credits-Limit"] = result.CreditsLimit.ToString(); + ctx.Response.Headers["X-Credits-Reset"] = result.ResetAt.ToString("O"); + } + + // ── Per-IP rate limiting ────────────────────────────────────────────────── + + private (bool allowed, bool isHour) CheckIpRateLimit(string ip, int cost) + { + var perMin = config.GetValue("RateLimit:PerIpPerMinute", 60); + var perHour = config.GetValue("RateLimit:PerIpPerHour", 500); + var now = DateTime.UtcNow; + + var minKey = $"rl:min:{ip}:{now:yyyyMMddHHmm}"; + var hourKey = $"rl:hr:{ip}:{now:yyyyMMddHH}"; + + var minUsed = cache.GetOrCreate(minKey, e => { e.AbsoluteExpiration = now.AddMinutes(1); return 0; }); + var hourUsed = cache.GetOrCreate(hourKey, e => { e.AbsoluteExpiration = now.AddHours(1); return 0; }); + + if (perHour > 0 && hourUsed + cost > perHour) + return (false, isHour: true); + + if (perMin > 0 && minUsed + cost > perMin) + return (false, isHour: false); + + cache.Set(minKey, minUsed + cost, new MemoryCacheEntryOptions { AbsoluteExpiration = now.AddMinutes(1) }); + cache.Set(hourKey, hourUsed + cost, new MemoryCacheEntryOptions { AbsoluteExpiration = now.AddHours(1) }); + + return (true, isHour: false); + } + + // ── Shared-IP detection (free plan abuse) ───────────────────────────────── + // Tracks distinct free keys seen from each IP per day. + // >3 distinct keys from same IP = likely key pooling. + + private void TrackIpKeyUsage(string ip, string apiKey, string today) + { + var trackKey = $"ipkeys:{ip}:{today}"; + var keys = cache.GetOrCreate(trackKey, e => + { + e.AbsoluteExpiration = DateTime.UtcNow.Date.AddDays(1); + return new HashSet(); + })!; + + lock (keys) + { + keys.Add(apiKey); + + if (keys.Count >= 4) + { + // Throttle ALL keys from this IP for the rest of the day + var throttleKey = $"ipthrottle:{ip}:{today}"; + cache.Set(throttleKey, true, new MemoryCacheEntryOptions + { + AbsoluteExpiration = DateTime.UtcNow.Date.AddDays(1) + }); + } + } + } + + public bool IsIpThrottled(string ip) + { + var today = DateTime.UtcNow.ToString("yyyy-MM-dd"); + return cache.TryGetValue($"ipthrottle:{ip}:{today}", out _); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private int GetPlanLimit(string plan, string key) + { + var v = config.GetValue($"Plans:{plan}:{key}"); + return v ?? 0; // 0 = unlimited + } + + private static object Build429Body(string plan, int used, int limit, DateTime resetAt, + bool isDailyLimit = false) => new + { + error = isDailyLimit ? "daily_limit_exceeded" : "credits_exhausted", + message = isDailyLimit + ? $"Limite diário de {limit} créditos atingido no plano gratuito. Renova amanhã ou faça upgrade." + : $"Seus créditos do mês acabaram. Seu plano ({Capitalize(plan)}) permite {limit:N0} créditos/mês.", + credits_used = used, + credits_limit = limit, + reset_at = resetAt.ToString("O"), + upgrade_url = "https://naluai.com/precos", + hint = isDailyLimit + ? "Plano Starter: 15.000 créditos/mês sem limite diário. R$ 29/mês." + : "Upgrade para Starter por apenas R$ 0,0019 por validação.", + }; + + private static string Capitalize(string s) => + s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..]; +} diff --git a/src/Nalu.Web/Services/DeterministicLayer.cs b/src/Nalu.Web/Services/DeterministicLayer.cs new file mode 100644 index 0000000..7d4cdc6 --- /dev/null +++ b/src/Nalu.Web/Services/DeterministicLayer.cs @@ -0,0 +1,125 @@ +using System.Text.RegularExpressions; +using Nalu.Web.Models; + +namespace Nalu.Web.Services; + +public enum DeterministicOutcome +{ + Unresolved, + Rejected, + Accepted, + ConstraintFailed +} + +public record DeterministicResult +{ + public DeterministicOutcome Outcome { get; init; } + public string? ExtractedValue { get; init; } + public string? Reasoning { get; init; } +} + +public class DeterministicLayer +{ + public DeterministicResult Evaluate(ValidatorDefinition validator, string userInput, string language = "pt-BR") + { + var normalized = userInput.Trim().ToLowerInvariant().TrimEnd('.', '!', '?', ',', ';'); + + // Use localized stop words when available, fall back to flat set + var stopWords = validator.LocalizedStopWords.TryGetValue(language, out var localized) + ? localized + : validator.StopWords; + + if (stopWords.Contains(normalized)) + { + return new DeterministicResult + { + Outcome = DeterministicOutcome.Rejected, + Reasoning = "Usuário respondeu com saudação ou palavra de parada" + }; + } + + // Reject patterns + foreach (var pattern in validator.RejectPatterns) + { + try + { + if (Regex.IsMatch(normalized, pattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100))) + { + return new DeterministicResult + { + Outcome = DeterministicOutcome.Rejected, + Reasoning = "Resposta corresponde a padrão de rejeição" + }; + } + } + catch (RegexMatchTimeoutException) { /* skip on timeout */ } + } + + // Accept patterns — capture group 1 is the extracted value + foreach (var pattern in validator.AcceptPatterns) + { + try + { + var m = Regex.Match(normalized, pattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100)); + if (!m.Success) continue; + + var extracted = m.Groups.Count > 1 && m.Groups[1].Success + ? m.Groups[1].Value.Trim() + : userInput.Trim(); + + var violation = CheckConstraints(validator.Constraints, extracted); + if (violation is not null) + { + return new DeterministicResult + { + Outcome = DeterministicOutcome.ConstraintFailed, + ExtractedValue = extracted, + Reasoning = violation + }; + } + + return new DeterministicResult + { + Outcome = DeterministicOutcome.Accepted, + ExtractedValue = extracted, + Reasoning = "Padrão de aceitação encontrado" + }; + } + catch (RegexMatchTimeoutException) { /* skip on timeout */ } + } + + return new DeterministicResult { Outcome = DeterministicOutcome.Unresolved }; + } + + private static string? CheckConstraints(Dictionary constraints, string value) + { + if (constraints.TryGetValue("min_length", out var minStr) && int.TryParse(minStr, out var min)) + { + if (value.Length < min) + return $"Valor muito curto (mínimo {min} caracteres)"; + } + + if (constraints.TryGetValue("max_length", out var maxStr) && int.TryParse(maxStr, out var max)) + { + if (value.Length > max) + return $"Valor muito longo (máximo {max} caracteres)"; + } + + if (constraints.TryGetValue("max_digits", out var maxDigStr) && int.TryParse(maxDigStr, out var maxDig)) + { + var digitCount = value.Count(char.IsDigit); + if (digitCount > maxDig) + return $"Número de dígitos excede o máximo permitido ({maxDig})"; + } + + if (constraints.TryGetValue("must_have_alpha", out var alphaStr) + && bool.TryParse(alphaStr, out var mustHaveAlpha) + && mustHaveAlpha) + { + if (!value.Any(char.IsLetter)) + return "Valor deve conter letras"; + } + + return null; + } +} diff --git a/src/Nalu.Web/Services/EnrichmentService.cs b/src/Nalu.Web/Services/EnrichmentService.cs new file mode 100644 index 0000000..ce2a877 --- /dev/null +++ b/src/Nalu.Web/Services/EnrichmentService.cs @@ -0,0 +1,32 @@ +using Nalu.Web.Enrichers; +using Nalu.Web.Models; + +namespace Nalu.Web.Services; + +public class EnrichmentService +{ + private readonly Dictionary _enrichers; + + public EnrichmentService(IEnumerable enrichers) + { + _enrichers = enrichers.ToDictionary(e => e.Name, StringComparer.OrdinalIgnoreCase); + } + + public async Task EnrichAsync(ValidatorDefinition validator, string? value, CancellationToken ct) + { + if (value is null || validator.Enrichers.Count == 0) + return EnrichmentResult.Ok(value); + + foreach (var name in validator.Enrichers) + { + if (value is null) break; + if (!_enrichers.TryGetValue(name, out var enricher)) continue; + + var result = await enricher.EnrichAsync(value, ct); + if (result.WasInvalidated) return EnrichmentResult.NotFound(); + value = result.Value; + } + + return EnrichmentResult.Ok(value); + } +} diff --git a/src/Nalu.Web/Services/ExtractionPipeline.cs b/src/Nalu.Web/Services/ExtractionPipeline.cs new file mode 100644 index 0000000..adbb3cf --- /dev/null +++ b/src/Nalu.Web/Services/ExtractionPipeline.cs @@ -0,0 +1,168 @@ +using Nalu.Web.Models; + +namespace Nalu.Web.Services; + +public class ExtractionPipeline +{ + private readonly ValidatorLoader _loader; + private readonly DeterministicLayer _deterministic; + private readonly LlmExtractionService _llm; + private readonly PostProcessorRegistry _postProcessors; + private readonly EnrichmentService _enrichment; + private readonly SuggestionBuilder _suggestions; + private readonly SmartSuggestionService _smartSuggestion; + private readonly CacheService _cache; + + public ExtractionPipeline( + ValidatorLoader loader, + DeterministicLayer deterministic, + LlmExtractionService llm, + PostProcessorRegistry postProcessors, + EnrichmentService enrichment, + SuggestionBuilder suggestions, + SmartSuggestionService smartSuggestion, + CacheService cache) + { + _loader = loader; + _deterministic = deterministic; + _llm = llm; + _postProcessors = postProcessors; + _enrichment = enrichment; + _suggestions = suggestions; + _smartSuggestion = smartSuggestion; + _cache = cache; + } + + public async Task ExecuteAsync( + string validatorId, + ExtractionRequest request, + CancellationToken ct = default) + { + if (_cache.TryGet(validatorId, request, out var cached) && cached is not null) + return cached; + + var validator = _loader.Load(validatorId); + var det = _deterministic.Evaluate(validator, request.UserInput, request.Language); + + ExtractionResponse response = det.Outcome switch + { + DeterministicOutcome.Accepted => + await BuildFromValueAsync(validator, request, det.ExtractedValue, + highConfidence: true, ct), + + DeterministicOutcome.Rejected => + BuildFailed(validator, request, wasInvalidated: false), + + DeterministicOutcome.ConstraintFailed => + BuildFailed(validator, request, wasInvalidated: true), + + _ => await BuildFromLlmAsync(validator, request, ct) + }; + + // Smart suggestion: replace static template with a contextual LLM re-ask + // when obtained=false and the request carries agent context. + if (!response.Obtained && HasContext(request)) + { + var smart = await _smartSuggestion.GenerateAsync(validator, request, ct); + if (smart is not null) + response = response with { SuggestionToAgent = smart }; + } + + _cache.Set(validatorId, request, response); + return response; + } + + private async Task BuildFromValueAsync( + ValidatorDefinition validator, + ExtractionRequest request, + string? rawValue, + bool highConfidence, + CancellationToken ct) + { + var procOutput = _postProcessors.Apply(validator.PostProcessors, rawValue); + + if (!procOutput.IsValid) + { + var sug = _suggestions.Build(validator, request, null, + obtained: false, certain: false, wasInvalidated: true); + return new ExtractionResponse + { + Obtained = false, ExtractedValue = null, Confidence = "low", + Certain = false, SuggestionToAgent = sug + }; + } + + var enrichResult = await _enrichment.EnrichAsync(validator, procOutput.Value, ct); + + if (enrichResult.WasInvalidated) + { + var sug = _suggestions.Build(validator, request, null, + obtained: false, certain: false, wasInvalidated: true); + return new ExtractionResponse + { + Obtained = false, ExtractedValue = null, Confidence = "low", + Certain = false, SuggestionToAgent = sug + }; + } + + var value = enrichResult.Value; + var obtained = value is not null; + var certain = obtained && highConfidence && !procOutput.WasCorrected; + + var suggestion = _suggestions.Build(validator, request, value, + obtained, certain, + wasInvalidated: false, + wasCorrected: procOutput.WasCorrected, + originalValue: procOutput.OriginalValue, + suggestionKeyOverride: procOutput.SuggestionKeyOverride); + + var confidence = obtained + ? (certain ? (highConfidence ? "high" : "medium") : "medium") + : "low"; + + var valueFormat = value is null ? null + : value.TrimStart().StartsWith('{') ? "object" + : "scalar"; + + return new ExtractionResponse + { + Obtained = obtained, + ExtractedValue = value, + Confidence = confidence, + Certain = certain, + SuggestionToAgent = suggestion, + ValueFormat = valueFormat + }; + } + + private ExtractionResponse BuildFailed( + ValidatorDefinition validator, + ExtractionRequest request, + bool wasInvalidated) + { + var suggestion = _suggestions.Build(validator, request, null, + obtained: false, certain: false, wasInvalidated: wasInvalidated); + + return new ExtractionResponse + { + Obtained = false, ExtractedValue = null, Confidence = "low", + Certain = false, SuggestionToAgent = suggestion + }; + } + + private async Task BuildFromLlmAsync( + ValidatorDefinition validator, + ExtractionRequest request, + CancellationToken ct) + { + var llm = await _llm.ExtractAsync(validator, request, ct); + + return await BuildFromValueAsync( + validator, request, llm.ExtractedValue, + highConfidence: llm.Certain, ct); + } + + private static bool HasContext(ExtractionRequest request) => + !string.IsNullOrWhiteSpace(request.AgentInput) || + !string.IsNullOrWhiteSpace(request.AgentContext); +} diff --git a/src/Nalu.Web/Services/LlmExtractionService.cs b/src/Nalu.Web/Services/LlmExtractionService.cs new file mode 100644 index 0000000..a9ac95a --- /dev/null +++ b/src/Nalu.Web/Services/LlmExtractionService.cs @@ -0,0 +1,156 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Nalu.Web.Infrastructure; +using Nalu.Web.Models; + +namespace Nalu.Web.Services; + +public record LlmExtractionResult +{ + public string? ExtractedValue { get; init; } + public bool Certain { get; init; } + public string? Reasoning { get; init; } + public required string Engine { get; init; } +} + +public class LlmExtractionService +{ + private readonly GroqClient _groq; + // private readonly OpenRouterClient _openRouter; // disabled — Prompt 2 + + public LlmExtractionService(GroqClient groq) + { + _groq = groq; + // _openRouter = openRouter; + } + + public async Task ExtractAsync( + ValidatorDefinition validator, + ExtractionRequest request, + CancellationToken ct = default) + { + var systemPrompt = BuildSystemPrompt(validator); + var userMessage = BuildUserMessage(request); + + var result = await _groq.ChatAsync(systemPrompt, userMessage, ct); + + // if (result.ShouldFallback) + // { + // result = await _openRouter.ChatAsync(systemPrompt, userMessage, ct); + // engine = "llm_openrouter"; + // } + + return ParseLlmResult(result, "llm_groq"); + } + + private static string BuildSystemPrompt(ValidatorDefinition validator) + { + var sb = new StringBuilder(); + + // Extract instructions (everything before "Diálogo:") + var promptText = validator.Prompt; + var dialogIdx = promptText.IndexOf("Diálogo:", StringComparison.Ordinal); + + if (dialogIdx >= 0) + { + sb.AppendLine(promptText[..dialogIdx].Trim()); + + // Append output format instruction (after the last template placeholder) + var ctxPlaceholder = "{{agent_context}}"; + var ctxEnd = promptText.IndexOf(ctxPlaceholder, StringComparison.Ordinal); + if (ctxEnd >= 0) + { + var afterCtx = promptText[(ctxEnd + ctxPlaceholder.Length)..].Trim(); + if (!string.IsNullOrWhiteSpace(afterCtx)) + { + sb.AppendLine(); + sb.AppendLine(afterCtx); + } + } + } + else + { + sb.AppendLine(promptText.Trim()); + } + + // Few-shot examples + if (validator.FewShotExamples.Count > 0) + { + sb.AppendLine("\nExemplos:"); + foreach (var ex in validator.FewShotExamples) + { + sb.AppendLine("---"); + sb.AppendLine($"Agente: {ex.AgentInput}"); + sb.AppendLine($"Usuário: {ex.UserInput}"); + sb.AppendLine($"Output: {ex.Output}"); + } + sb.AppendLine("---"); + } + + return sb.ToString().Trim(); + } + + private static string BuildUserMessage(ExtractionRequest request) => + $""" + Diálogo: + Agente: {request.AgentInput} + Usuário: {request.UserInput} + Contexto do agente: {request.AgentContext ?? ""} + """; + + private static LlmExtractionResult ParseLlmResult(LlmCallResult result, string engine) + { + if (result.Content is null) + { + return new LlmExtractionResult + { + ExtractedValue = null, + Certain = false, + Reasoning = result.Error ?? "LLM call failed", + Engine = engine + }; + } + + try + { + var parsed = result.Content.Deserialize( + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (parsed is null) + return new LlmExtractionResult { Certain = false, Engine = engine, Reasoning = "Failed to parse LLM response" }; + + // Normalize string "null" to actual null + var extracted = parsed.ExtractedValue == "null" ? null : parsed.ExtractedValue; + + return new LlmExtractionResult + { + ExtractedValue = extracted, + Certain = parsed.Certain, + Reasoning = parsed.Reasoning, + Engine = engine + }; + } + catch + { + return new LlmExtractionResult + { + Certain = false, + Reasoning = "Could not parse LLM JSON response", + Engine = engine + }; + } + } + + private record LlmJsonResponse + { + [JsonPropertyName("extracted_value")] + public string? ExtractedValue { get; init; } + + [JsonPropertyName("certain")] + public bool Certain { get; init; } + + [JsonPropertyName("reasoning")] + public string? Reasoning { get; init; } + } +} diff --git a/src/Nalu.Web/Services/LlmRouter/GoogleAiProvider.cs b/src/Nalu.Web/Services/LlmRouter/GoogleAiProvider.cs new file mode 100644 index 0000000..4925c0d --- /dev/null +++ b/src/Nalu.Web/Services/LlmRouter/GoogleAiProvider.cs @@ -0,0 +1,74 @@ +using System.Diagnostics; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Nalu.Web.Services.LlmRouter; + +/// +/// Google AI Studio via OpenAI-compatible endpoint. +/// +public class GoogleAiProvider(HttpClient http, IConfiguration config, ILogger logger) : ILlmProvider +{ + public string Name => "google-ai"; + + public async Task CompleteAsync(LlmRequest request, CancellationToken ct) + { + var model = config["GoogleAi:Model"] ?? "gemini-2.0-flash"; + var body = new + { + model, + messages = new[] + { + new { role = "system", content = request.SystemPrompt }, + new { role = "user", content = request.UserMessage } + }, + max_tokens = request.MaxTokens, + temperature = request.Temperature, + response_format = new { type = "json_object" } + }; + + var sw = Stopwatch.StartNew(); + using var httpReq = new HttpRequestMessage(HttpMethod.Post, "chat/completions") + { + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }; + + HttpResponseMessage resp; + try + { + resp = await http.SendAsync(httpReq, ct); + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + throw new TimeoutException("Google AI request timed out"); + } + + if (resp.StatusCode == HttpStatusCode.TooManyRequests) + throw new RateLimitException(Name); + + if ((int)resp.StatusCode >= 500) + { + logger.LogWarning("Google AI server error {Status}", resp.StatusCode); + throw new RateLimitException(Name); + } + + if (!resp.IsSuccessStatusCode) + { + var err = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException($"Google AI {resp.StatusCode}: {err}"); + } + + var json = await resp.Content.ReadAsStringAsync(ct); + var doc = JsonNode.Parse(json); + var content = doc?["choices"]?[0]?["message"]?["content"]?.GetValue(); + + return new LlmResponse + { + Content = content ?? throw new InvalidOperationException("Empty content in Google AI response"), + Provider = Name, + LatencyMs = (int)sw.ElapsedMilliseconds + }; + } +} diff --git a/src/Nalu.Web/Services/LlmRouter/GroqProvider.cs b/src/Nalu.Web/Services/LlmRouter/GroqProvider.cs new file mode 100644 index 0000000..415c57e --- /dev/null +++ b/src/Nalu.Web/Services/LlmRouter/GroqProvider.cs @@ -0,0 +1,73 @@ +using System.Diagnostics; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Nalu.Web.Services.LlmRouter; + +public class GroqProvider(HttpClient http, IConfiguration config, ILogger logger) : ILlmProvider +{ + public string Name => "groq"; + + public async Task CompleteAsync(LlmRequest request, CancellationToken ct) + { + var model = config["Groq:Model"] ?? "llama-3.3-70b-versatile"; + var body = BuildBody(model, request); + + var sw = Stopwatch.StartNew(); + using var httpReq = new HttpRequestMessage(HttpMethod.Post, "chat/completions") + { + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }; + + HttpResponseMessage resp; + try + { + resp = await http.SendAsync(httpReq, ct); + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + throw new TimeoutException("Groq request timed out"); + } + + if (resp.StatusCode == HttpStatusCode.TooManyRequests) + throw new RateLimitException(Name); + + if ((int)resp.StatusCode >= 500) + { + logger.LogWarning("Groq server error {Status}", resp.StatusCode); + throw new RateLimitException(Name); // treat as transient + } + + if (!resp.IsSuccessStatusCode) + { + var err = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException($"Groq {resp.StatusCode}: {err}"); + } + + var content = await ExtractContent(resp, ct); + return new LlmResponse { Content = content, Provider = Name, LatencyMs = (int)sw.ElapsedMilliseconds }; + } + + private static object BuildBody(string model, LlmRequest r) => new + { + model, + messages = new[] + { + new { role = "system", content = r.SystemPrompt }, + new { role = "user", content = r.UserMessage } + }, + max_tokens = r.MaxTokens, + temperature = r.Temperature, + response_format = new { type = "json_object" } + }; + + private static async Task ExtractContent(HttpResponseMessage resp, CancellationToken ct) + { + var json = await resp.Content.ReadAsStringAsync(ct); + var doc = JsonNode.Parse(json); + var content = doc?["choices"]?[0]?["message"]?["content"]?.GetValue(); + return content ?? throw new InvalidOperationException("Empty content in Groq response"); + } +} diff --git a/src/Nalu.Web/Services/LlmRouter/ILlmProvider.cs b/src/Nalu.Web/Services/LlmRouter/ILlmProvider.cs new file mode 100644 index 0000000..cbdc1b3 --- /dev/null +++ b/src/Nalu.Web/Services/LlmRouter/ILlmProvider.cs @@ -0,0 +1,7 @@ +namespace Nalu.Web.Services.LlmRouter; + +public interface ILlmProvider +{ + string Name { get; } + Task CompleteAsync(LlmRequest request, CancellationToken ct); +} diff --git a/src/Nalu.Web/Services/LlmRouter/ILlmRouter.cs b/src/Nalu.Web/Services/LlmRouter/ILlmRouter.cs new file mode 100644 index 0000000..50ab508 --- /dev/null +++ b/src/Nalu.Web/Services/LlmRouter/ILlmRouter.cs @@ -0,0 +1,6 @@ +namespace Nalu.Web.Services.LlmRouter; + +public interface ILlmRouter +{ + Task CompleteAsync(LlmRequest request, CancellationToken ct); +} diff --git a/src/Nalu.Web/Services/LlmRouter/LlmModels.cs b/src/Nalu.Web/Services/LlmRouter/LlmModels.cs new file mode 100644 index 0000000..b29e27a --- /dev/null +++ b/src/Nalu.Web/Services/LlmRouter/LlmModels.cs @@ -0,0 +1,23 @@ +namespace Nalu.Web.Services.LlmRouter; + +public record LlmRequest +{ + public required string SystemPrompt { get; init; } + public required string UserMessage { get; init; } + public int MaxTokens { get; init; } = 800; + public double Temperature { get; init; } = 0.1; +} + +public record LlmResponse +{ + public required string Content { get; init; } + public required string Provider { get; init; } + public int LatencyMs { get; init; } +} + +public class RateLimitException(string provider) : Exception($"{provider} rate limited") +{ + public string Provider { get; } = provider; +} + +public class ServiceUnavailableException(string message) : Exception(message); diff --git a/src/Nalu.Web/Services/LlmRouter/LlmRouter.cs b/src/Nalu.Web/Services/LlmRouter/LlmRouter.cs new file mode 100644 index 0000000..4919d4b --- /dev/null +++ b/src/Nalu.Web/Services/LlmRouter/LlmRouter.cs @@ -0,0 +1,29 @@ +namespace Nalu.Web.Services.LlmRouter; + +public class LlmRouter(IReadOnlyList providers, ILogger logger) : ILlmRouter +{ + public async Task CompleteAsync(LlmRequest request, CancellationToken ct) + { + foreach (var provider in providers) + { + try + { + return await provider.CompleteAsync(request, ct); + } + catch (RateLimitException ex) + { + logger.LogWarning("Provider {Provider} rate limited, trying next", ex.Provider); + } + catch (TimeoutException) + { + logger.LogWarning("Provider {Provider} timed out, trying next", provider.Name); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Provider {Provider} failed, trying next", provider.Name); + } + } + + throw new ServiceUnavailableException("All LLM providers unavailable"); + } +} diff --git a/src/Nalu.Web/Services/LlmRouter/OpenRouterProvider.cs b/src/Nalu.Web/Services/LlmRouter/OpenRouterProvider.cs new file mode 100644 index 0000000..ab2b28d --- /dev/null +++ b/src/Nalu.Web/Services/LlmRouter/OpenRouterProvider.cs @@ -0,0 +1,71 @@ +using System.Diagnostics; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Nalu.Web.Services.LlmRouter; + +public class OpenRouterProvider(HttpClient http, IConfiguration config, ILogger logger) : ILlmProvider +{ + public string Name => "openrouter"; + + public async Task CompleteAsync(LlmRequest request, CancellationToken ct) + { + var model = config["OpenRouter:Model"] ?? "meta-llama/llama-3.3-70b-instruct"; + var body = new + { + model, + messages = new[] + { + new { role = "system", content = request.SystemPrompt }, + new { role = "user", content = request.UserMessage } + }, + max_tokens = request.MaxTokens, + temperature = request.Temperature, + response_format = new { type = "json_object" } + }; + + var sw = Stopwatch.StartNew(); + using var httpReq = new HttpRequestMessage(HttpMethod.Post, "chat/completions") + { + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }; + + HttpResponseMessage resp; + try + { + resp = await http.SendAsync(httpReq, ct); + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + throw new TimeoutException("OpenRouter request timed out"); + } + + if (resp.StatusCode == HttpStatusCode.TooManyRequests) + throw new RateLimitException(Name); + + if ((int)resp.StatusCode >= 500) + { + logger.LogWarning("OpenRouter server error {Status}", resp.StatusCode); + throw new RateLimitException(Name); + } + + if (!resp.IsSuccessStatusCode) + { + var err = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException($"OpenRouter {resp.StatusCode}: {err}"); + } + + var json = await resp.Content.ReadAsStringAsync(ct); + var doc = JsonNode.Parse(json); + var content = doc?["choices"]?[0]?["message"]?["content"]?.GetValue(); + + return new LlmResponse + { + Content = content ?? throw new InvalidOperationException("Empty content in OpenRouter response"), + Provider = Name, + LatencyMs = (int)sw.ElapsedMilliseconds + }; + } +} diff --git a/src/Nalu.Web/Services/PostProcessorRegistry.cs b/src/Nalu.Web/Services/PostProcessorRegistry.cs new file mode 100644 index 0000000..74e3b50 --- /dev/null +++ b/src/Nalu.Web/Services/PostProcessorRegistry.cs @@ -0,0 +1,61 @@ +using Nalu.Web.PostProcessors; + +namespace Nalu.Web.Services; + +public record PostProcessorOutput +{ + public string? Value { get; init; } + public bool IsValid { get; init; } = true; + public bool WasCorrected { get; init; } + public string? OriginalValue { get; init; } + public string? InvalidReason { get; init; } + /// Overrides suggestion template key — set by processors like CalculateAge ("when_minor"). + public string? SuggestionKeyOverride { get; init; } +} + +public class PostProcessorRegistry +{ + private readonly Dictionary _processors; + + public PostProcessorRegistry(IEnumerable processors) + { + _processors = processors.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase); + } + + public PostProcessorOutput Apply(IReadOnlyList processorNames, string? value) + { + bool wasCorrected = false; + string? originalValue = null; + string? suggestionKeyOverride = null; + + foreach (var name in processorNames) + { + if (!_processors.TryGetValue(name, out var processor)) continue; + + var result = processor.Process(value); + + if (!result.IsValid) + return new PostProcessorOutput { Value = null, IsValid = false, InvalidReason = result.InvalidReason }; + + if (result.WasCorrected) + { + wasCorrected = true; + originalValue ??= result.OriginalValue; + } + + if (result.SuggestionKeyOverride is not null) + suggestionKeyOverride = result.SuggestionKeyOverride; + + value = result.Value; + } + + return new PostProcessorOutput + { + Value = value, + IsValid = true, + WasCorrected = wasCorrected, + OriginalValue = originalValue, + SuggestionKeyOverride = suggestionKeyOverride + }; + } +} diff --git a/src/Nalu.Web/Services/RateLimitService.cs b/src/Nalu.Web/Services/RateLimitService.cs new file mode 100644 index 0000000..f7acb3a --- /dev/null +++ b/src/Nalu.Web/Services/RateLimitService.cs @@ -0,0 +1,80 @@ +using System.Collections.Concurrent; +using Nalu.Web.Data.Repositories; + +namespace Nalu.Web.Services; + +public class RateLimitService(IConfiguration config, UsageRepository usageRepo) +{ + private readonly ConcurrentDictionary _fallback = new(); + + /// + /// Returns true and increments counters if the key is within limits. + /// Uses MongoDB when connected; falls back to in-memory. + /// + public async Task TryConsumeAsync(string apiKey, string plan, CancellationToken ct = default) + { + var planSection = config.GetSection($"Plans:{plan}"); + var dailyLimit = planSection.GetValue("daily_limit"); + var monthlyLimit = planSection.GetValue("monthly_limit"); + + // No limits configured — always allow + if (!dailyLimit.HasValue && !monthlyLimit.HasValue) return true; + + var mongoResult = await usageRepo.IncrementAsync(apiKey, plan, ct); + + if (mongoResult.HasValue) + { + var (daily, monthly) = mongoResult.Value; + if (dailyLimit.HasValue && daily > dailyLimit.Value) return false; + if (monthlyLimit.HasValue && monthly > monthlyLimit.Value) return false; + return true; + } + + // Fallback: in-memory + return TryConsumeFallback(apiKey, dailyLimit, monthlyLimit); + } + + // Kept for sync callers and backwards compat during migration + public bool TryConsume(string apiKey, string plan) + { + var planSection = config.GetSection($"Plans:{plan}"); + var dailyLimit = planSection.GetValue("daily_limit"); + var monthlyLimit = planSection.GetValue("monthly_limit"); + + if (!dailyLimit.HasValue && !monthlyLimit.HasValue) return true; + + return TryConsumeFallback(apiKey, dailyLimit, monthlyLimit); + } + + private bool TryConsumeFallback(string apiKey, int? dailyLimit, int? monthlyLimit) + { + var now = DateTime.UtcNow; + var today = DateOnly.FromDateTime(now); + var month = new YearMonth(now.Year, now.Month); + + var record = _fallback.GetOrAdd(apiKey, _ => new InMemoryUsage(today, month)); + + lock (record) + { + if (record.Day != today) { record.Day = today; record.DailyCount = 0; } + if (record.Month != month) { record.Month = month; record.MonthlyCount = 0; } + + if (dailyLimit.HasValue && record.DailyCount >= dailyLimit.Value) return false; + if (monthlyLimit.HasValue && record.MonthlyCount >= monthlyLimit.Value) return false; + + record.DailyCount++; + record.MonthlyCount++; + return true; + } + } + + private sealed class InMemoryUsage(DateOnly day, YearMonth month) + { + public DateOnly Day { get; set; } = day; + public int DailyCount { get; set; } + public YearMonth Month { get; set; } = month; + public int MonthlyCount { get; set; } + } + + private record YearMonth(int Year, int Month); +} diff --git a/src/Nalu.Web/Services/ReplyService.cs b/src/Nalu.Web/Services/ReplyService.cs new file mode 100644 index 0000000..ee92bf7 --- /dev/null +++ b/src/Nalu.Web/Services/ReplyService.cs @@ -0,0 +1,160 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Nalu.Web.Models; +using Nalu.Web.Services.LlmRouter; + +namespace Nalu.Web.Services; + +public class ReplyService(ILlmRouter llmRouter, ILogger logger) +{ + private const string SystemPrompt = """ + You are a conversational reply analyzer. Given an agent's message and the user's reply, determine: + 1. The type of reply (from a fixed taxonomy) + 2. What the user actually meant (considering the context of the agent's message) + 3. Whether clarification is needed + 4. A suggested follow-up message for the agent (in the language specified) + + Reply types (pick exactly one): + - answer: User directly answered what was asked + - question: User asked a question back + - counter_proposal: User proposed an alternative to what was offered + - confirmation: User confirmed positively + - rejection: User rejected or refused + - off_topic: User talked about something unrelated + - greeting: User sent a greeting without useful content + - handoff: User wants to talk to a human + - cancel: User wants to quit or cancel + - unclear: Ambiguous, needs clarification + + CRITICAL: When the agent mentions specific numbers (prices, quantities, installments) and the user replies with a different number, carefully analyze whether the user is: + - Proposing a different QUANTITY (e.g., different number of installments) + - Proposing a different AMOUNT (e.g., different price) + - Confirming the original number + The context of the agent's message determines the meaning of the user's number. + + value_type options: "quantity", "amount", "date", "text", "boolean" — or null if no value extracted. + + Respond ONLY in JSON format, no preamble, no markdown: + { + "reply_type": "string", + "extracted_value": "string or null", + "value_type": "string or null", + "extracted_meaning": "string explaining what the user meant", + "confidence": float 0-1, + "needs_clarification": boolean, + "suggestion_to_agent": "string or null" + } + """; + + public async Task AnalyzeAsync(ReplyRequest request, CancellationToken ct = default) + { + var userMessage = BuildUserMessage(request); + + try + { + var llmResp = await llmRouter.CompleteAsync(new LlmRequest + { + SystemPrompt = SystemPrompt, + UserMessage = userMessage, + MaxTokens = 600, + Temperature = 0.1 + }, ct); + + return ParseResponse(llmResp); + } + catch (ServiceUnavailableException ex) + { + logger.LogError(ex, "All LLM providers failed for validate_reply"); + throw; + } + } + + private static string BuildUserMessage(ReplyRequest r) => + $""" + Language: {r.Language} + + Agent message: {r.AgentMessage} + User reply: {r.UserReply} + + Generate suggestion_to_agent in {r.Language}. + """; + + private static ReplyResponse ParseResponse(LlmResponse llmResp) + { + try + { + var parsed = JsonSerializer.Deserialize( + llmResp.Content, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (parsed is null) + return Failed(); + + var replyType = ParseReplyType(parsed.ReplyType); + + return new ReplyResponse + { + Obtained = replyType is not null, + ReplyType = replyType, + ExtractedValue = parsed.ExtractedValue, + ValueType = parsed.ValueType, + ExtractedMeaning = parsed.ExtractedMeaning, + Confidence = Math.Clamp(parsed.Confidence, 0.0, 1.0), + NeedsClarification = parsed.NeedsClarification, + SuggestionToAgent = parsed.SuggestionToAgent + }; + } + catch + { + return Failed(); + } + } + + private static ReplyType? ParseReplyType(string? raw) => raw?.ToLowerInvariant() switch + { + "answer" => Models.ReplyType.Answer, + "question" => Models.ReplyType.Question, + "counter_proposal" => Models.ReplyType.CounterProposal, + "confirmation" => Models.ReplyType.Confirmation, + "rejection" => Models.ReplyType.Rejection, + "off_topic" => Models.ReplyType.OffTopic, + "greeting" => Models.ReplyType.Greeting, + "handoff" => Models.ReplyType.Handoff, + "cancel" => Models.ReplyType.Cancel, + "unclear" => Models.ReplyType.Unclear, + _ => null + }; + + private static ReplyResponse Failed() => new() + { + Obtained = false, + Confidence = 0.0, + NeedsClarification = true, + ExtractedMeaning = "Could not analyze reply" + }; + + private record LlmReplyJson + { + [JsonPropertyName("reply_type")] + public string? ReplyType { get; init; } + + [JsonPropertyName("extracted_value")] + public string? ExtractedValue { get; init; } + + [JsonPropertyName("value_type")] + public string? ValueType { get; init; } + + [JsonPropertyName("extracted_meaning")] + public string? ExtractedMeaning { get; init; } + + [JsonPropertyName("confidence")] + public double Confidence { get; init; } + + [JsonPropertyName("needs_clarification")] + public bool NeedsClarification { get; init; } + + [JsonPropertyName("suggestion_to_agent")] + public string? SuggestionToAgent { get; init; } + } +} diff --git a/src/Nalu.Web/Services/SmartSuggestionService.cs b/src/Nalu.Web/Services/SmartSuggestionService.cs new file mode 100644 index 0000000..1829543 --- /dev/null +++ b/src/Nalu.Web/Services/SmartSuggestionService.cs @@ -0,0 +1,92 @@ +using Nalu.Web.Models; +using Nalu.Web.Services.LlmRouter; + +namespace Nalu.Web.Services; + +/// +/// When obtained=false and the request carries agent context, calls the LLM to generate +/// a natural, conversational re-ask — answering the user's question if they asked one, +/// then politely re-requesting the needed information. +/// Falls back silently to null so the caller keeps the static template suggestion. +/// +public class SmartSuggestionService(ILlmRouter llm, ILogger logger) +{ + public async Task GenerateAsync( + ValidatorDefinition validator, + ExtractionRequest request, + CancellationToken ct = default) + { + // Only generate when we have enough context to be useful + if (string.IsNullOrWhiteSpace(request.AgentInput) && string.IsNullOrWhiteSpace(request.AgentContext)) + return null; + + var what = ValidatorPurpose(validator); + var lang = LanguageName(request.Language); + + var system = $""" + You are helping an AI agent have a natural conversation with a user. + The agent needs to collect: {what}. + Reply language: {lang}. Always reply in this language only. + Be concise: 1–3 sentences maximum. + Do NOT mention AI, extraction, or that the input was invalid. + Reply ONLY with the agent's next message. No quotes, no explanation, no JSON. + """; + + var contextBlock = string.IsNullOrWhiteSpace(request.AgentContext) + ? "" + : $"\nAgent context: {request.AgentContext}"; + + var agentBlock = string.IsNullOrWhiteSpace(request.AgentInput) + ? "" + : $"\nAgent said: \"{request.AgentInput}\""; + + var user = $""" + {contextBlock}{agentBlock} + User responded: "{request.UserInput}" + + The user did not provide the required {what}. + Write a single natural reply that: + 1. If the user asked a question, answer it briefly and politely. + 2. Then re-ask for the {what} naturally. + 3. If the user was evasive or unclear, gently re-ask without being repetitive. + """; + + try + { + var result = await llm.CompleteAsync(new LlmRequest + { + SystemPrompt = system, + UserMessage = user, + MaxTokens = 120, + Temperature = 0.4, + }, ct); + + var text = result.Content.Trim().Trim('"'); + return string.IsNullOrWhiteSpace(text) ? null : text; + } + catch (Exception ex) + { + logger.LogWarning(ex, "SmartSuggestion LLM call failed for {Validator}", validator.Id); + return null; + } + } + + private static string ValidatorPurpose(ValidatorDefinition v) + { + // Use MCP description first sentence, fall back to validator ID + if (!string.IsNullOrWhiteSpace(v.McpDescription)) + { + var first = v.McpDescription.Split('.')[0].Trim(); + if (first.Length > 0) return first.ToLower(); + } + return v.Id.Replace("validate_", "").Replace("_", " "); + } + + private static string LanguageName(string bcp47) => bcp47 switch + { + "pt-BR" => "Brazilian Portuguese", + "en-US" => "English", + "es-ES" => "Spanish", + _ => bcp47, + }; +} diff --git a/src/Nalu.Web/Services/SuggestionBuilder.cs b/src/Nalu.Web/Services/SuggestionBuilder.cs new file mode 100644 index 0000000..3ed5b53 --- /dev/null +++ b/src/Nalu.Web/Services/SuggestionBuilder.cs @@ -0,0 +1,119 @@ +using System.Text.Json.Nodes; +using Nalu.Web.Models; + +namespace Nalu.Web.Services; + +public class SuggestionBuilder +{ + private static readonly Dictionary GreetingMap = new(StringComparer.OrdinalIgnoreCase) + { + { "bom dia", "Bom dia!" }, + { "boa tarde", "Boa tarde!" }, + { "boa noite", "Boa noite!" }, + { "olá", "Olá!" }, + { "ola", "Olá!" }, + { "oi", "Oi!" }, + { "eae", "Oi!" }, + { "e aí", "Oi!" }, + { "fala", "Oi!" }, + { "opa", "Oi!" } + }; + + public string? Build( + ValidatorDefinition validator, + ExtractionRequest request, + string? extractedValue, + bool obtained, + bool certain, + bool wasInvalidated = false, + bool wasCorrected = false, + string? originalValue = null, + string? suggestionKeyOverride = null) + { + var templateKey = suggestionKeyOverride + ?? SelectTemplateKey(request.UserInput, obtained, certain, wasInvalidated, wasCorrected); + + var template = GetTemplate(validator, templateKey, request.Language); + if (string.IsNullOrWhiteSpace(template)) return null; + + return ExpandPlaceholders(template, extractedValue, request.UserInput, originalValue); + } + + private static string? GetTemplate(ValidatorDefinition validator, string key, string language) + { + // Try language-specific first + if (validator.LocalizedSuggestions.TryGetValue(language, out var localized) + && localized.TryGetValue(key, out var localizedText)) + return localizedText; + + // Fall back to flat suggestions + return validator.Suggestions.TryGetValue(key, out var flat) ? flat : null; + } + + private static string SelectTemplateKey( + string userInput, bool obtained, bool certain, bool wasInvalidated, bool wasCorrected) + { + if (!obtained) + { + if (wasInvalidated) return "when_invalid"; + return IsGreeting(userInput) ? "when_null_greeting" : "when_null_evasive"; + } + + if (!certain) + { + if (wasCorrected) return "when_corrected"; + return "when_uncertain"; + } + + return "when_certain"; + } + + private static string ExpandPlaceholders( + string template, string? extractedValue, string userInput, string? originalValue) + { + var greeting = DetectGreetingResponse(userInput); + + var result = template + .Replace("{{extracted_value}}", extractedValue ?? "") + .Replace("{{greeting_response}}", greeting) + .Replace("{{original}}", originalValue ?? ""); + + // JSON field expansion — for enriched values like CEP address + if (extractedValue?.TrimStart().StartsWith('{') == true) + { + try + { + var obj = JsonNode.Parse(extractedValue)?.AsObject(); + if (obj != null) + { + foreach (var prop in obj) + result = result.Replace($"{{{{{prop.Key}}}}}", prop.Value?.GetValue() ?? ""); + } + } + catch { /* non-JSON, skip */ } + } + + return result; + } + + private static bool IsGreeting(string userInput) + { + var normalized = userInput.Trim().ToLowerInvariant().TrimEnd('.', '!', '?', ','); + return GreetingMap.Keys.Any(g => + normalized == g + || normalized.StartsWith(g + " ", StringComparison.Ordinal) + || normalized.StartsWith(g + ",", StringComparison.Ordinal) + || normalized.StartsWith(g + "!", StringComparison.Ordinal)); + } + + private static string DetectGreetingResponse(string userInput) + { + var normalized = userInput.Trim().ToLowerInvariant(); + foreach (var (key, response) in GreetingMap) + { + if (normalized.StartsWith(key, StringComparison.Ordinal)) + return response; + } + return ""; + } +} diff --git a/src/Nalu.Web/Services/UserService.cs b/src/Nalu.Web/Services/UserService.cs new file mode 100644 index 0000000..a8b2d45 --- /dev/null +++ b/src/Nalu.Web/Services/UserService.cs @@ -0,0 +1,78 @@ +using System.Security.Cryptography; +using Nalu.Web.Data.Models; +using Nalu.Web.Data.Repositories; + +namespace Nalu.Web.Services; + +public class UserService(UserRepository users, ApiKeyRepository apiKeys, ILogger logger) +{ + public record LoginResult(NaluUser User, ApiKey ApiKey, bool IsNew); + + public async Task LoginOrCreateAsync( + string provider, string providerId, + string email, string? name, string? pictureUrl, + CancellationToken ct = default) + { + // Find existing user + var user = await users.FindByProviderAsync(provider, providerId, ct); + + if (user is not null) + { + // Update profile on each login + user.Name = name; + user.PictureUrl = pictureUrl; + user.LastLoginAt = DateTime.UtcNow; + await users.UpsertAsync(user, ct); + + var existingKey = (await apiKeys.GetByUserAsync(user.Id, ct)).FirstOrDefault(); + if (existingKey is not null) + return new LoginResult(user, existingKey, IsNew: false); + + // User exists but somehow has no key — create one + var key = await CreateApiKeyAsync(user, ct); + return new LoginResult(user, key, IsNew: false); + } + + // New user + user = new NaluUser + { + Email = email, + Name = name, + Provider = provider, + ProviderId = providerId, + PictureUrl = pictureUrl, + Plan = "free", + }; + await users.UpsertAsync(user, ct); + + // Re-fetch to get DB-assigned id after upsert + user = await users.FindByProviderAsync(provider, providerId, ct) ?? user; + + var newKey = await CreateApiKeyAsync(user, ct); + logger.LogInformation("New user created: {Email} via {Provider}", email, provider); + + return new LoginResult(user, newKey, IsNew: true); + } + + private async Task CreateApiKeyAsync(NaluUser user, CancellationToken ct) + { + var rawKey = $"nalu-{GenerateRandomBase62(32)}"; + var apiKey = new ApiKey + { + Key = rawKey, + Plan = user.Plan, + Owner = user.Email, + UserId = user.Id, + Label = "Default", + }; + await apiKeys.InsertAsync(apiKey, ct); + return apiKey; + } + + private static string GenerateRandomBase62(int length) + { + const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + var bytes = RandomNumberGenerator.GetBytes(length); + return new string(bytes.Select(b => chars[b % chars.Length]).ToArray()); + } +} diff --git a/src/Nalu.Web/Services/ValidatorLoader.cs b/src/Nalu.Web/Services/ValidatorLoader.cs new file mode 100644 index 0000000..5d5dfe4 --- /dev/null +++ b/src/Nalu.Web/Services/ValidatorLoader.cs @@ -0,0 +1,294 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; +using Nalu.Web.Models; + +namespace Nalu.Web.Services; + +public class ValidatorLoader : IDisposable +{ + private readonly string _validatorsPath; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _cache = new(); + private FileSystemWatcher? _watcher; + + public ValidatorLoader(ILogger logger, IWebHostEnvironment env) + { + _logger = logger; + _validatorsPath = Path.Combine(env.ContentRootPath, "Validators"); + + if (Directory.Exists(_validatorsPath)) + { + _watcher = new FileSystemWatcher(_validatorsPath, "*.md") + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName, + EnableRaisingEvents = true + }; + + _watcher.Changed += Invalidate; + _watcher.Deleted += Invalidate; + _watcher.Renamed += Invalidate; + } + } + + private void Invalidate(object _, FileSystemEventArgs e) + { + var id = Path.GetFileNameWithoutExtension(e.Name ?? ""); + if (_cache.TryRemove(id, out ValidatorDefinition? _)) + _logger.LogInformation("Invalidated validator cache for '{Id}'", id); + } + + public ValidatorDefinition Load(string validatorId) + { + return _cache.GetOrAdd(validatorId, id => + { + var path = Path.Combine(_validatorsPath, $"{id}.md"); + if (!File.Exists(path)) + throw new FileNotFoundException($"Validator '{id}' not found at '{path}'"); + + var content = File.ReadAllText(path); + _logger.LogDebug("Parsed validator '{Id}' from disk", id); + return ParseFromContent(id, content); + }); + } + + public IEnumerable LoadAll() + { + if (!Directory.Exists(_validatorsPath)) + return []; + + return Directory + .GetFiles(_validatorsPath, "*.md") + .Select(f => Load(Path.GetFileNameWithoutExtension(f))); + } + + /// Parses a validator .md string. Public for unit testing. + public static ValidatorDefinition ParseFromContent(string id, string content) + { + var def = new ValidatorDefinition { Id = id }; + + // Description: text between # title line and first ## section + var descMatch = Regex.Match(content, + @"^#[^\n]+\n(.*?)(?=^##|\z)", + RegexOptions.Multiline | RegexOptions.Singleline); + if (descMatch.Success) + def.Description = descMatch.Groups[1].Value.Trim(); + + // Split into ## sections + var sections = Regex.Split(content, @"^## ", RegexOptions.Multiline); + + foreach (var section in sections) + { + if (string.IsNullOrWhiteSpace(section)) continue; + + var nlIdx = section.IndexOf('\n'); + if (nlIdx < 0) continue; + + var sectionName = section[..nlIdx].Trim().ToLowerInvariant(); + var body = section[(nlIdx + 1)..]; + + switch (sectionName) + { + case "config": + ParseConfig(def, body); + break; + case "deterministic_rules": + ParseDeterministicRules(def, body); + break; + case "prompt": + def.Prompt = body.Trim(); + break; + case "few_shot_examples": + ParseFewShots(def, body); + break; + case "post_processors": + def.PostProcessors = ParseBulletList(body); + break; + case "enrichment": + if (!body.Contains("nenhum", StringComparison.OrdinalIgnoreCase)) + def.Enrichers = ParseBulletList(body); + break; + case "suggestions": + ParseSuggestions(def, body); + break; + } + } + + return def; + } + + private static void ParseConfig(ValidatorDefinition def, string body) + { + foreach (var line in body.Split('\n')) + { + var m = Regex.Match(line.Trim(), @"^-\s+(\w+):\s*(.+)$"); + if (!m.Success) continue; + + var key = m.Groups[1].Value; + var value = m.Groups[2].Value.Trim(); + + switch (key) + { + case "type": def.Type = value; break; + case "version": def.Version = value; break; + case "languages": + def.Languages = value.Split(',').Select(l => l.Trim()).ToList(); + break; + case "endpoint": def.Endpoint = value; break; + case "mcp_tool": def.McpTool = value; break; + case "mcp_description": def.McpDescription = value; break; + } + } + } + + private static void ParseDeterministicRules(ValidatorDefinition def, string body) + { + var subsections = Regex.Split(body, @"^### ", RegexOptions.Multiline); + + foreach (var sub in subsections) + { + if (string.IsNullOrWhiteSpace(sub)) continue; + + var nlIdx = sub.IndexOf('\n'); + if (nlIdx < 0) continue; + + var subName = sub[..nlIdx].Trim().ToLowerInvariant(); + var subBody = sub[(nlIdx + 1)..]; + + switch (subName) + { + case "stop_words": + if (subBody.Contains("#### ")) + { + // Localized stop words per language (#### pt-BR / #### en-US / ...) + var langSecs = Regex.Split(subBody, @"^#### ", RegexOptions.Multiline); + foreach (var langSec in langSecs) + { + if (string.IsNullOrWhiteSpace(langSec)) continue; + var lnl = langSec.IndexOf('\n'); + if (lnl < 0) continue; + var lang = langSec[..lnl].Trim(); + var words = langSec[(lnl + 1)..].Trim() + .Split(',') + .Select(s => s.Trim().ToLowerInvariant()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToHashSet(); + def.LocalizedStopWords[lang] = words; + foreach (var w in words) def.StopWords.Add(w); // flat fallback + } + } + else + { + def.StopWords = subBody + .Trim() + .Split(',') + .Select(s => s.Trim().ToLowerInvariant()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToHashSet(); + } + break; + + case "reject_patterns": + def.RejectPatterns = ParseBulletList(subBody); + break; + + case "accept_patterns": + def.AcceptPatterns = ParseBulletList(subBody); + break; + + case "constraints": + def.Constraints = ParseBulletList(subBody) + .Select(l => l.Split(':', 2)) + .Where(p => p.Length == 2) + .ToDictionary(p => p[0].Trim(), p => p[1].Trim()); + break; + } + } + } + + private static void ParseFewShots(ValidatorDefinition def, string body) + { + var examples = Regex.Split(body, @"^### example \d+", RegexOptions.Multiline | RegexOptions.IgnoreCase); + + foreach (var ex in examples) + { + if (string.IsNullOrWhiteSpace(ex)) continue; + + var agentInput = ExtractBulletValue(ex, "agent_input"); + var userInput = ExtractBulletValue(ex, "user_input"); + var output = ExtractBulletValue(ex, "output"); + + if (agentInput is not null && userInput is not null && output is not null) + { + def.FewShotExamples.Add(new FewShotExample + { + AgentInput = agentInput, + UserInput = userInput, + Output = output + }); + } + } + } + + private static string? ExtractBulletValue(string text, string key) + { + var m = Regex.Match(text, $@"^-\s+{Regex.Escape(key)}:\s*(.+)$", RegexOptions.Multiline); + return m.Success ? m.Groups[1].Value.Trim() : null; + } + + private static void ParseSuggestions(ValidatorDefinition def, string body) + { + var subsections = Regex.Split(body, @"^### ", RegexOptions.Multiline); + + foreach (var sub in subsections) + { + if (string.IsNullOrWhiteSpace(sub)) continue; + + var nlIdx = sub.IndexOf('\n'); + if (nlIdx < 0) continue; + + var key = sub[..nlIdx].Trim().ToLowerInvariant(); + var rawValue = sub[(nlIdx + 1)..]; + + if (string.IsNullOrEmpty(key)) continue; + + if (rawValue.Contains("#### ")) + { + // Localized suggestion per language + var langSecs = Regex.Split(rawValue, @"^#### ", RegexOptions.Multiline); + foreach (var langSec in langSecs) + { + if (string.IsNullOrWhiteSpace(langSec)) continue; + var lnl = langSec.IndexOf('\n'); + if (lnl < 0) continue; + var lang = langSec[..lnl].Trim(); + var text = langSec[(lnl + 1)..].Trim(); + if (string.IsNullOrWhiteSpace(text) || text.StartsWith('(')) continue; + + if (!def.LocalizedSuggestions.ContainsKey(lang)) + def.LocalizedSuggestions[lang] = new Dictionary(StringComparer.OrdinalIgnoreCase); + def.LocalizedSuggestions[lang][key] = text; + } + } + else + { + var value = rawValue.Trim(); + if (value.StartsWith('(')) continue; // placeholder like "(sem sugestão)" + def.Suggestions[key] = value; + } + } + } + + private static List ParseBulletList(string body) => + body.Split('\n') + .Select(l => l.Trim()) + .Where(l => l.StartsWith("- ")) + .Select(l => l[2..].Trim()) + .Where(l => !string.IsNullOrEmpty(l)) + .ToList(); + + public void Dispose() + { + _watcher?.Dispose(); + _watcher = null; + } +} diff --git a/src/Nalu.Web/Validators/validate_birthdate.md b/src/Nalu.Web/Validators/validate_birthdate.md new file mode 100644 index 0000000..ed31803 --- /dev/null +++ b/src/Nalu.Web/Validators/validate_birthdate.md @@ -0,0 +1,151 @@ +# validate_birthdate + +Extracts date of birth and calculates age from conversation. + +## config + +- type: extraction +- version: 1.0 +- languages: pt-BR, en-US, es-ES +- endpoint: /v1/extract/birthdate +- mcp_tool: nalu_extract_birthdate +- mcp_description: Extracts the user's date of birth from conversation. Supports multiple formats (DD/MM/YYYY, "March 15, 1990", "quinze de março de noventa"). Automatically calculates current age. Returns certain=false if the date is ambiguous or approximate. If has_suggestion=true and the key is when_minor, the user is under 18 — proceed according to your flow. If certain=true, accept the value and continue. + +## deterministic_rules + +### stop_words +#### pt-BR +não lembro, não sei, não tenho certeza, bom dia, boa tarde, boa noite, olá, oi + +#### en-US +i don't know, i don't remember, not sure, hello, hi, good morning, good afternoon + +#### es-ES +no recuerdo, no sé, hola, buenos días, buenas tardes + +### reject_patterns +# Removed: ^[a-zA-Z\s]+$ blocked written-out dates like "quince de marzo de noventa". +# Stop_words handle greetings. LLM handles evasive responses. + +### accept_patterns +- (\d{4}-\d{2}-\d{2}) +- ((?:0?[1-9]|[12]\d|3[01])[/.\-](?:0?[1-9]|1[0-2])[/.\-]\d{4}) +- ((?:1[3-9]|[2-3]\d)[/.\-]\d{2}[/.\-]\d{4}) + +### constraints +- min_length: 6 + +## prompt + +You are a date of birth extractor. Given the dialogue below, extract the user's date of birth. + +Rules: +1. Extract dates in any format (numeric, written, partial). +2. Normalize to ISO 8601 format: YYYY-MM-DD. +3. If the user gave only their age ("I'm 36"), estimate the birth year as current year minus age. Return certain: false and date as YYYY-01-01. +4. If pt-BR or es-ES: assume DD/MM/YYYY for ambiguous dates. If en-US: assume MM/DD/YYYY. +5. If the date is in the future or older than 130 years, return extracted_value: null. +6. If the user was evasive, return extracted_value: null. + +Dialogue: +Agent: {{agent_input}} +User: {{user_input}} + +Agent context: {{agent_context}} + +Reply ONLY with valid JSON, no markdown, no explanation: +{ + "extracted_value": "YYYY-MM-DD or null", + "certain": true/false, + "reasoning": "short explanation" +} + +## few_shot_examples + +### example 1 +- agent_input: What's your date of birth? +- user_input: 03/15/1990 +- output: {"extracted_value": "1990-03-15", "certain": true, "reasoning": "Standard US date format MM/DD/YYYY"} + +### example 2 +- agent_input: Qual sua data de nascimento? +- user_input: quinze de março de noventa +- output: {"extracted_value": "1990-03-15", "certain": true, "reasoning": "Date written in full in Portuguese"} + +### example 3 +- agent_input: When were you born? +- user_input: I'm 36 +- output: {"extracted_value": "1989-01-01", "certain": false, "reasoning": "Only age provided — birth year estimated, day/month unknown"} + +### example 4 +- agent_input: Data de nascimento? +- user_input: não lembro exatamente +- output: {"extracted_value": null, "certain": false, "reasoning": "User does not remember their birth date"} + +### example 5 +- agent_input: When were you born? +- user_input: 15/03/2030 +- output: {"extracted_value": null, "certain": false, "reasoning": "Date is in the future — invalid birth date"} + +### example 6 +- agent_input: Qual sua data de nascimento? +- user_input: 15/03/1990 +- output: {"extracted_value": "1990-03-15", "certain": true, "reasoning": "Date in DD/MM/YYYY format (pt-BR)"} + +NOTE: The LLM must return the ISO date string (YYYY-MM-DD). The parse_date and calculate_age post-processors transform it to JSON: {"date":"1990-03-15","formatted":"15/03/1990","age":35,"minor":false}. The final API response will contain the JSON string in extracted_value with value_format="object". + +## post_processors +- parse_date +- calculate_age + +## suggestions + +### when_null_evasive +#### pt-BR +Preciso da sua data de nascimento para continuar. Qual é? (Ex: 15/03/1990) + +#### en-US +I need your date of birth to continue. When were you born? (e.g. 03/15/1990) + +#### es-ES +Necesito tu fecha de nacimiento para continuar. ¿Cuál es? (Ej: 15/03/1990) + +### when_uncertain +#### pt-BR +Só para confirmar: sua data de nascimento é {{formatted}}, e você tem {{age}} anos. Está correto? + +#### en-US +Just to confirm: your date of birth is {{formatted}}, making you {{age}} years old. Is that correct? + +#### es-ES +Para confirmar: tu fecha de nacimiento es {{formatted}}, tienes {{age}} años. ¿Es correcto? + +### when_certain +#### pt-BR +Perfeito! Data de nascimento: {{formatted}} ({{age}} anos). + +#### en-US +Got it! Date of birth: {{formatted}} ({{age}} years old). + +#### es-ES +¡Perfecto! Fecha de nacimiento: {{formatted}} ({{age}} años). + +### when_invalid +#### pt-BR +Essa data não parece correta. Pode verificar e tentar novamente? Formato: DD/MM/AAAA + +#### en-US +That date doesn't seem right. Could you check and try again? Format: MM/DD/YYYY + +#### es-ES +Esa fecha no parece correcta. ¿Puedes verificar e intentar de nuevo? Formato: DD/MM/AAAA + +### when_minor +#### pt-BR +Atenção: o usuário parece ter menos de 18 anos ({{age}} anos). Verifique as regras do seu fluxo para menores de idade. + +#### en-US +Note: the user appears to be under 18 years old ({{age}} years old). Please check your flow's rules for minors. + +#### es-ES +Nota: el usuario parece ser menor de 18 años ({{age}} años). Verifique las reglas de su flujo para menores. diff --git a/src/Nalu.Web/Validators/validate_cancel_intent.md b/src/Nalu.Web/Validators/validate_cancel_intent.md new file mode 100644 index 0000000..f3c6a92 --- /dev/null +++ b/src/Nalu.Web/Validators/validate_cancel_intent.md @@ -0,0 +1,132 @@ +# validate_cancel_intent + +Detects cancellation intent — service/subscription vs current operation vs frustration threat. + +## config + +- type: extraction +- version: 1.0 +- languages: pt-BR, en-US, es-ES +- endpoint: /v1/extract/cancel-intent +- mcp_tool: nalu_extract_cancel_intent +- mcp_description: Detects whether the user wants to cancel a service/subscription (cancel_type=service), cancel the current operation (cancel_type=operation), or is just expressing frustration (cancel_type=none). Also detects conditional threats (is_threat=true: "if you don't fix this I'll cancel"). The bot developer decides the retention flow — NALU only classifies. Use has_suggestion to route the response. + +## deterministic_rules + +### stop_words +#### pt-BR +bom dia, boa tarde, olá, oi, tudo bem, ok, certo + +#### en-US +hello, hi, good morning, ok, alright, sure + +#### es-ES +hola, buenos días, ok, bien + +### reject_patterns +- ^[a-zA-Z\s]{1,4}$ + +### accept_patterns +- (cancelar plano|cancelar assinatura|quero cancelar|desistir do serviço|encerrar contrato|não quero mais|quero sair) +- (cancel my subscription|cancel my account|cancel my plan|i want to cancel|i'd like to cancel|cancel service|stop my subscription) +- (quiero cancelar|cancelar mi suscripción|cancelar mi cuenta|no quiero más) +- (cancela isso|volta|desfaz|para tudo|cancel that|undo|go back|stop) + +### constraints +- min_length: 3 + +## prompt + +You are a cancellation intent classifier. Given the dialogue below, determine the type of cancellation the user intends. + +Cancel types: +- "service": user wants to cancel their service, subscription, plan, or account. +- "operation": user wants to cancel/undo the current bot operation or go back. +- "none": user is not canceling — just frustrated or thinking about it. + +Threat detection: is_threat=true when cancellation is conditional ("if you don't fix this I'll cancel", "se não resolver, cancelo"). + +Rules: +1. "Quero cancelar meu plano" → service, is_threat: false. +2. "Cancela isso, volta pro menu" → operation, is_threat: false. +3. "Se não resolver vou cancelar tudo" → service, is_threat: true, certain: false. +4. "Tô pensando em cancelar..." → none, certain: false. +5. Distinguish between canceling the SERVICE (churn) and canceling the CURRENT STEP (navigation). + +Dialogue: +Agent: {{agent_input}} +User: {{user_input}} + +Agent context: {{agent_context}} + +Reply ONLY with valid JSON, no markdown: +{ + "extracted_value": "{\"cancel_type\":\"service/operation/none\",\"certainty_score\":0.0-1.0,\"is_threat\":true/false}", + "certain": true/false, + "reasoning": "short explanation" +} + +## few_shot_examples + +### example 1 +- agent_input: How can I help? +- user_input: I want to cancel my subscription +- output: {"extracted_value": "{\"cancel_type\":\"service\",\"certainty_score\":0.97,\"is_threat\":false}", "certain": true, "reasoning": "Direct service cancellation request"} + +### example 2 +- agent_input: Confirm your order? +- user_input: no cancel that, go back +- output: {"extracted_value": "{\"cancel_type\":\"operation\",\"certainty_score\":0.95,\"is_threat\":false}", "certain": true, "reasoning": "User canceling current operation, not the service"} + +### example 3 +- agent_input: I'm looking into it +- user_input: if you don't fix this I'm canceling everything +- output: {"extracted_value": "{\"cancel_type\":\"service\",\"certainty_score\":0.75,\"is_threat\":true}", "certain": false, "reasoning": "Conditional threat — not yet a firm decision to cancel"} + +### example 4 +- agent_input: Posso ajudar? +- user_input: tô pensando em cancelar minha assinatura +- output: {"extracted_value": "{\"cancel_type\":\"none\",\"certainty_score\":0.60,\"is_threat\":false}", "certain": false, "reasoning": "User considering cancellation but hasn't decided"} + +### example 5 +- agent_input: ¿En qué puedo ayudarte? +- user_input: quiero cancelar mi suscripción +- output: {"extracted_value": "{\"cancel_type\":\"service\",\"certainty_score\":0.98,\"is_threat\":false}", "certain": true, "reasoning": "Direct service cancellation in Spanish"} + +## post_processors +- select_cancel_suggestion + +## suggestions + +### when_cancel_service +#### pt-BR +Entendo que você quer cancelar. Antes de prosseguirmos, há algo que podemos resolver para que você fique? + +#### en-US +I understand you'd like to cancel. Before we proceed, is there anything we can resolve to keep you? + +#### es-ES +Entiendo que deseas cancelar. Antes de proceder, ¿hay algo que podamos resolver para que te quedes? + +### when_cancel_operation +#### pt-BR +Entendido! Vou cancelar essa etapa. O que você gostaria de fazer agora? + +#### en-US +Got it, I'll cancel that. What would you like to do instead? + +#### es-ES +Entendido. Voy a cancelar eso. ¿Qué te gustaría hacer ahora? + +### when_threat +#### pt-BR +Lamento os problemas. Vou fazer o meu melhor para resolver isso agora. + +#### en-US +I'm sorry for the trouble. Let me try to resolve this for you right now. + +#### es-ES +Lamento los problemas. Déjame intentar resolver esto ahora mismo. + +### when_not_cancel +(no suggestion — continue conversation) diff --git a/src/Nalu.Web/Validators/validate_cep.md b/src/Nalu.Web/Validators/validate_cep.md new file mode 100644 index 0000000..f59621f --- /dev/null +++ b/src/Nalu.Web/Validators/validate_cep.md @@ -0,0 +1,87 @@ +# validate_cep + +Extrai o CEP do usuário e consulta o endereço via ViaCEP. + +## config + +- type: extraction +- version: 1.0 +- languages: pt-BR +- endpoint: /v1/extract/cep +- mcp_tool: nalu_extract_cep +- mcp_description: Extrai o CEP do usuário e retorna o endereço completo (logradouro, bairro, cidade, estado). Se certain=true e suggestion_to_agent não é null, confirme o endereço com o usuário antes de prosseguir. Se obtained=false, o CEP não foi encontrado — use suggestion_to_agent para pedir novamente. + +## deterministic_rules + +### stop_words +bom dia, boa tarde, boa noite, olá, oi, não sei, não lembro + +### reject_patterns +- ^[a-zA-Z\s]+$ +- ^0{5}-?0{3}$ + +### accept_patterns +- (\d{5}-?\d{3}) +- (\d{8}) + +### constraints +- min_length: 8 + +## prompt + +Você é um extrator de CEP. Dado o diálogo abaixo, extraia o CEP que o usuário informou. + +Regras: +1. Extraia apenas os 8 dígitos numéricos do CEP. +2. Se o usuário não forneceu o CEP ou foi evasivo, retorne extracted_value: null. +3. Não valide o CEP — apenas extraia os dígitos. + +Diálogo: +Agente: {{agent_input}} +Usuário: {{user_input}} + +Contexto do agente: {{agent_context}} + +Responda SOMENTE com JSON válido, sem markdown, sem explicação: +{ + "extracted_value": "8 dígitos numéricos do CEP ou null", + "certain": true/false, + "reasoning": "explicação curta" +} + +## few_shot_examples + +### example 1 +- agent_input: Qual é o seu CEP? +- user_input: 01001-000 +- output: {"extracted_value": "01001000", "certain": true, "reasoning": "CEP no formato padrão"} + +### example 2 +- agent_input: Me diga seu CEP de entrega. +- user_input: Meu cep é 04538133 +- output: {"extracted_value": "04538133", "certain": true, "reasoning": "CEP extraído da frase"} + +### example 3 +- agent_input: Qual é o seu CEP? +- user_input: Não sei de cabeça +- output: {"extracted_value": null, "certain": false, "reasoning": "Usuário não forneceu o CEP"} + +## post_processors +- format_cep + +## enrichment +- viacep + +## suggestions + +### when_null_evasive +Preciso do seu CEP para identificar o endereço. Pode informar? Formato: XXXXX-XXX + +### when_uncertain +Encontrei o seguinte endereço: {{formatted_address}}. Está correto? + +### when_certain +Ótimo! Encontrei seu endereço: {{formatted_address}}. Posso usar esse endereço? + +### when_invalid +Esse CEP não foi encontrado ou é inválido. Pode verificar e digitar novamente? Formato: XXXXX-XXX diff --git a/src/Nalu.Web/Validators/validate_cnpj.md b/src/Nalu.Web/Validators/validate_cnpj.md new file mode 100644 index 0000000..fcf754c --- /dev/null +++ b/src/Nalu.Web/Validators/validate_cnpj.md @@ -0,0 +1,86 @@ +# validate_cnpj + +Extrai e valida CNPJ brasileiro (14 dígitos com algoritmo mod 11). + +## config + +- type: extraction +- version: 1.0 +- 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. + +## deterministic_rules + +### stop_words +bom dia, boa tarde, boa noite, olá, oi, não tenho, nao tenho, não sei, sem cnpj + +### reject_patterns +- ^[a-zA-Z\s]+$ +- ^(\d)\1{13}$ + +### accept_patterns +- (\d{2}[\.\s]?\d{3}[\.\s]?\d{3}[\/\s]?\d{4}[-\s]?\d{2}) +- (\d{14}) + +### constraints +- min_length: 14 + +## prompt + +Você é um extrator de CNPJ. Dado o diálogo abaixo, extraia o CNPJ que a empresa informou. + +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. + +Diálogo: +Agente: {{agent_input}} +Usuário: {{user_input}} + +Contexto do agente: {{agent_context}} + +Responda SOMENTE com JSON válido, sem markdown: +{ + "extracted_value": "14 dígitos do CNPJ ou null", + "certain": true/false, + "reasoning": "explicação curta" +} + +## few_shot_examples + +### example 1 +- agent_input: Qual o CNPJ da empresa? +- user_input: 11.222.333/0001-81 +- output: {"extracted_value": "11222333000181", "certain": true, "reasoning": "CNPJ no formato padrão"} + +### example 2 +- agent_input: Informe o CNPJ. +- user_input: é 11222333000181 +- output: {"extracted_value": "11222333000181", "certain": true, "reasoning": "CNPJ sem formatação"} + +### example 3 +- agent_input: Qual o CNPJ da empresa? +- user_input: não tenho aqui agora +- output: {"extracted_value": null, "certain": false, "reasoning": "Usuário não forneceu o CNPJ"} + +### example 4 +- agent_input: CNPJ da empresa? +- user_input: 11.111.111/1111-11 +- output: {"extracted_value": "11111111111111", "certain": true, "reasoning": "CNPJ extraído — validação de dígitos feita pelo pós-processador"} + +## post_processors +- validate_cnpj_digit + +## suggestions + +### when_null_evasive +Preciso do CNPJ da empresa para continuar. Pode informar? São 14 dígitos (formato: XX.XXX.XXX/XXXX-XX) + +### when_invalid +Esse CNPJ parece estar incorreto. Pode verificar? São 14 dígitos (XX.XXX.XXX/XXXX-XX). + +### when_certain +(sem sugestão — agente segue o fluxo) diff --git a/src/Nalu.Web/Validators/validate_company_name.md b/src/Nalu.Web/Validators/validate_company_name.md new file mode 100644 index 0000000..bcbf528 --- /dev/null +++ b/src/Nalu.Web/Validators/validate_company_name.md @@ -0,0 +1,111 @@ +# validate_company_name + +Extracts company name from conversation, detecting legal suffixes and trade names. + +## config + +- type: extraction +- version: 1.0 +- languages: pt-BR, en-US, es-ES +- endpoint: /v1/extract/company-name +- mcp_tool: nalu_extract_company_name +- mcp_description: Extracts company name from conversation. Detects legal suffixes (LTDA, ME, S/A, LLC, Inc, GmbH, etc.) and separates the clean name from the suffix. Returns company_name, suffix, and clean_name. Works for any country with special handling for Brazilian company suffixes. + +## deterministic_rules + +### stop_words +#### pt-BR +não sei, não lembro, bom dia, boa tarde, olá, oi, pessoa física, autônomo, sem empresa + +#### en-US +i don't know, i don't remember, hello, hi, no company, individual, freelancer + +#### es-ES +no sé, no recuerdo, hola, persona física, autónomo, sin empresa + +### reject_patterns +- ^(sim|não|yes|no|sí|ok)$ +- ^[0-9\s\.\-]+$ + +### accept_patterns +- (\b\w[\w\s\.&,'-]{2,}(?:ltda|me|epp|eireli|s[/\.]?a|sa|llc|inc|corp|ltd|gmbh|bv|srl|sl)\b) +- (\b[A-Z][a-zA-Z\s&,'-]{3,}\b) + +### constraints +- min_length: 3 +- must_have_alpha: true + +## prompt + +You are a company name extractor. Given the dialogue below, extract the company name. + +Rules: +1. Extract the full company name including legal suffix (LTDA, LLC, Inc, etc.) if present. +2. Separate the clean name from the suffix. +3. Capitalize properly (Title Case for most names, preserve ALL CAPS if that appears intentional). +4. Remove context phrases: "a empresa", "a firma", "o nome é", "the company is", "trabajo en", etc. +5. Recognized suffixes: LTDA, ME, EPP, EIRELI, S/A, S.A., SA, LLC, Inc, Corp, Ltd, GmbH, BV, SRL, SL. +6. If the user gave an informal reference ("trabalho na tech solutions"), extract "Tech Solutions". +7. If no company name was provided, return extracted_value: null. + +Dialogue: +Agent: {{agent_input}} +User: {{user_input}} + +Agent context: {{agent_context}} + +Reply ONLY with valid JSON, no markdown: +{ + "extracted_value": "{\"company_name\":\"Full Name Ltda\",\"suffix\":\"LTDA\",\"clean_name\":\"Full Name\"}", + "certain": true/false, + "reasoning": "short explanation" +} + +## few_shot_examples + +### example 1 +- agent_input: What's the company name? +- user_input: Tech Solutions Ltda +- output: {"extracted_value": "{\"company_name\":\"Tech Solutions Ltda\",\"suffix\":\"LTDA\",\"clean_name\":\"Tech Solutions\"}", "certain": true, "reasoning": "Company name with Brazilian suffix LTDA"} + +### example 2 +- agent_input: Qual a empresa? +- user_input: trabalho na tech solutions +- output: {"extracted_value": "{\"company_name\":\"Tech Solutions\",\"suffix\":null,\"clean_name\":\"Tech Solutions\"}", "certain": true, "reasoning": "Company name extracted from informal sentence"} + +### example 3 +- agent_input: Company name? +- user_input: Acme Corporation Inc. +- output: {"extracted_value": "{\"company_name\":\"Acme Corporation Inc.\",\"suffix\":\"INC\",\"clean_name\":\"Acme Corporation\"}", "certain": true, "reasoning": "US company with Inc suffix"} + +### example 4 +- agent_input: Nome da empresa? +- user_input: ah deixa pra lá +- output: {"extracted_value": null, "certain": false, "reasoning": "User refused to provide company name"} + +### example 5 +- agent_input: What is your company's name? +- user_input: GlobalTech GmbH +- output: {"extracted_value": "{\"company_name\":\"GlobalTech GmbH\",\"suffix\":\"GMBH\",\"clean_name\":\"GlobalTech\"}", "certain": true, "reasoning": "German GmbH suffix detected"} + +## suggestions + +### when_null_evasive +#### pt-BR +Preciso do nome da empresa para continuar. Qual é o nome? (Ex: Tech Solutions Ltda) + +#### en-US +I need the company name to continue. What's the company's name? (e.g. Tech Solutions Inc.) + +#### es-ES +Necesito el nombre de la empresa para continuar. ¿Cuál es? (Ej: Tech Solutions S.L.) + +### when_uncertain +#### pt-BR +O nome da empresa é {{company_name}}? Pode confirmar? + +#### en-US +The company name is {{company_name}}? Can you confirm? + +#### es-ES +¿El nombre de la empresa es {{company_name}}? ¿Puedes confirmar? diff --git a/src/Nalu.Web/Validators/validate_cpf.md b/src/Nalu.Web/Validators/validate_cpf.md new file mode 100644 index 0000000..c803b5b --- /dev/null +++ b/src/Nalu.Web/Validators/validate_cpf.md @@ -0,0 +1,98 @@ +# validate_cpf + +Extrai e valida o CPF do usuário a partir do diálogo. + +## config + +- type: extraction +- version: 1.0 +- languages: pt-BR +- endpoint: /v1/extract/cpf +- mcp_tool: nalu_extract_cpf +- mcp_description: Extrai e valida o CPF do usuário. Valida dígitos verificadores automaticamente. Se certain=true, o CPF é válido e pode ser usado. 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 CPF. + +## deterministic_rules + +### stop_words +bom dia, boa tarde, boa noite, olá, oi + +### reject_patterns +- ^(não lembro|nao lembro|esqueci|sem cpf|não tenho|nao tenho|não sei|nao sei)$ +- ^[a-zA-Z\s]+$ + +### accept_patterns +- (\d{3}\.?\d{3}\.?\d{3}-?\d{2}) + +### constraints +- min_length: 11 + +## prompt + +Você é um extrator de CPF. Dado o diálogo abaixo, extraia o CPF que o usuário informou. + +Regras: +1. Extraia apenas os 11 dígitos numéricos — ignore pontuação e espaços. +2. Se o usuário disser o CPF por extenso ("um dois três..."), converta para dígitos. +3. Se o usuário foi evasivo ou não forneceu o CPF, retorne extracted_value: null. +4. Não valide os dígitos verificadores — apenas extraia os dígitos. +5. Se a resposta contiver 11 dígitos numéricos, extraia-os mesmo que estejam no meio de uma frase. + +Diálogo: +Agente: {{agent_input}} +Usuário: {{user_input}} + +Contexto do agente: {{agent_context}} + +Responda SOMENTE com JSON válido, sem markdown, sem explicação: +{ + "extracted_value": "11 dígitos numéricos ou null", + "certain": true/false, + "reasoning": "explicação curta" +} + +## few_shot_examples + +### example 1 +- agent_input: Qual é o seu CPF? +- user_input: 123.456.789-09 +- output: {"extracted_value": "12345678909", "certain": true, "reasoning": "CPF no formato padrão"} + +### example 2 +- agent_input: Preciso do seu CPF para continuar. +- user_input: Pode ser, meu cpf é 048 867 206 97 +- output: {"extracted_value": "04886720697", "certain": true, "reasoning": "CPF extraído do meio da frase"} + +### example 3 +- agent_input: Qual é o seu CPF? +- user_input: Não lembro o número +- output: {"extracted_value": null, "certain": false, "reasoning": "Usuário não forneceu o CPF"} + +### example 4 +- agent_input: Me informe seu CPF. +- user_input: zero quatro oito oito seis sete dois zero seis nove sete +- output: {"extracted_value": "04886720697", "certain": true, "reasoning": "CPF por extenso convertido para dígitos"} + +### example 5 +- agent_input: Qual seu CPF? +- user_input: 111.111.111-11 +- output: {"extracted_value": "11111111111", "certain": false, "reasoning": "Sequência repetida, provável erro ou teste"} + +## post_processors +- validate_cpf_digit + +## enrichment +(nenhum) + +## suggestions + +### when_null_evasive +Preciso do seu CPF para continuar. São 11 dígitos, pode digitar? + +### when_invalid +Esse CPF parece estar incorreto (dígitos verificadores não conferem). Pode verificar e digitar novamente? + +### when_uncertain +Só confirmando: seu CPF é {{extracted_value}}? Pode confirmar? + +### when_certain +(sem sugestão — agente segue o fluxo) diff --git a/src/Nalu.Web/Validators/validate_email.md b/src/Nalu.Web/Validators/validate_email.md new file mode 100644 index 0000000..dd8086b --- /dev/null +++ b/src/Nalu.Web/Validators/validate_email.md @@ -0,0 +1,101 @@ +# validate_email + +Extrai email do usuário com correção automática de typos em domínios comuns. + +## config + +- type: extraction +- version: 1.0 +- languages: pt-BR, en-US, es-ES +- endpoint: /v1/extract/email +- mcp_tool: nalu_extract_email +- mcp_description: Extrai o email do usuário com correção automática de typos em domínios comuns (gmail, hotmail, outlook). Se houve correção, certain=false e suggestion_to_agent pede confirmação. Se certain=true, o email está confirmado. Se obtained=false, use a sugestão para re-pedir. + +## deterministic_rules + +### stop_words +bom dia, boa tarde, boa noite, olá, oi, não tenho, não uso email + +### reject_patterns +- ^[^@\s]+$ +- ^(não|nao|sem|nenhum)$ + +### accept_patterns +- ([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}) + +### constraints +- min_length: 5 +- must_have_alpha: true + +## prompt + +Você é um extrator de email. Dado o diálogo abaixo, extraia o endereço de email que o usuário informou. + +Regras: +1. Extraia o email exatamente como o usuário digitou. +2. Se o usuário não forneceu email ou foi evasivo, retorne extracted_value: null. +3. Converta para letras minúsculas. +4. Não corrija typos — retorne o email como está. + +Diálogo: +Agente: {{agent_input}} +Usuário: {{user_input}} + +Contexto do agente: {{agent_context}} + +Responda SOMENTE com JSON válido, sem markdown, sem explicação: +{ + "extracted_value": "email extraído ou null", + "certain": true/false, + "reasoning": "explicação curta" +} + +## few_shot_examples + +### example 1 +- agent_input: Qual é o seu email? +- user_input: joao.silva@gmail.com +- output: {"extracted_value": "joao.silva@gmail.com", "certain": true, "reasoning": "Email válido informado diretamente"} + +### example 2 +- agent_input: Me passe seu email. +- user_input: Pode ser, é maria@gamil.com +- output: {"extracted_value": "maria@gamil.com", "certain": true, "reasoning": "Email extraído do meio da frase"} + +### example 3 +- agent_input: Qual seu email? +- user_input: Não tenho email não +- output: {"extracted_value": null, "certain": false, "reasoning": "Usuário declarou não ter email"} + +### example 4 +- agent_input: Pode me informar seu email? +- user_input: pedro arroba hotmail ponto com +- output: {"extracted_value": "pedro@hotmail.com", "certain": true, "reasoning": "Email dito por extenso convertido"} + +### example 5 +- agent_input: Qual é o email para contato? +- user_input: ana.costa@outlook.con +- output: {"extracted_value": "ana.costa@outlook.con", "certain": false, "reasoning": "Possível typo em .con"} + +## post_processors +- correct_email_typos + +## enrichment +(nenhum) + +## suggestions + +### when_null_evasive +Preciso do seu email para continuar. Pode informar? + +### when_corrected +Seu email é {{extracted_value}}? (identificamos um possível erro de digitação em '{{original}}') + +### when_uncertain +Seu email é {{extracted_value}}? Pode confirmar? + +### when_invalid +Esse email não parece válido. Pode digitar novamente? + +### when_certain +(sem sugestão — agente segue o fluxo) diff --git a/src/Nalu.Web/Validators/validate_full_name.md b/src/Nalu.Web/Validators/validate_full_name.md new file mode 100644 index 0000000..83c90db --- /dev/null +++ b/src/Nalu.Web/Validators/validate_full_name.md @@ -0,0 +1,110 @@ +# validate_full_name + +Extrai o nome completo do usuário 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. + +## deterministic_rules + +### stop_words +bom dia, boa tarde, boa noite, olá, oi, tudo bem, e aí, fala, eae, opa + +### reject_patterns +- ^(não|nao|sei la|sei lá|tanto faz|qualquer|nenhum|nada)$ +- ^\d+$ + +### accept_patterns +- ^meu nome é\s+(.+)$ +- ^me chamo\s+(.+)$ +- ^sou o\s+(.+)$ +- ^sou a\s+(.+)$ +- ^pode me chamar de\s+(.+)$ + +### constraints +- min_length: 2 +- must_have_alpha: 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. + +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). + +Diálogo: +Agente: {{agent_input}} +Usuário: {{user_input}} + +Contexto do agente: {{agent_context}} + +Responda SOMENTE com JSON válido, sem markdown, sem explicação: +{ + "extracted_value": "nome extraído ou null", + "certain": true/false, + "reasoning": "explicação curta de 1 linha" +} + +## few_shot_examples + +### example 1 +- agent_input: Bom dia! Qual seu nome completo? +- user_input: Bom dia! +- 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"} + +### 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"} + +### example 4 +- 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"} + +## post_processors +- capitalize_proper_name +- remove_titles + +## enrichment +(nenhum) + +## suggestions + +### when_null_greeting +{{greeting_response}} Mas preciso do seu nome completo para continuar. Pode me dizer? + +### when_null_evasive +Sem problemas, mas preciso do seu nome para prosseguir. Qual seu nome completo? + +### when_uncertain +Só confirmando: seu nome é {{extracted_value}}? Pode confirmar? + +### when_certain +(sem sugestão — agente segue o fluxo) diff --git a/src/Nalu.Web/Validators/validate_handoff.md b/src/Nalu.Web/Validators/validate_handoff.md new file mode 100644 index 0000000..370f248 --- /dev/null +++ b/src/Nalu.Web/Validators/validate_handoff.md @@ -0,0 +1,124 @@ +# validate_handoff + +Detects when the user wants to speak with a human agent. + +## config + +- type: extraction +- version: 1.0 +- languages: pt-BR, en-US, es-ES +- endpoint: /v1/extract/handoff +- mcp_tool: nalu_extract_handoff +- mcp_description: Detects when the user wants to speak with a human agent. Returns wants_human=true with urgency (low/medium/high) and frustration_detected. The bot developer decides what action to take — NALU only classifies. If obtained=true, check suggestion_to_agent for the recommended response script. Use has_suggestion to branch your flow. + +## deterministic_rules + +### stop_words +#### pt-BR +bom dia, boa tarde, boa noite, olá, oi, tudo bem, ok, certo + +#### en-US +hello, hi, good morning, good afternoon, ok, alright, sure + +#### es-ES +hola, buenos días, buenas tardes, ok, bien + +### reject_patterns +- ^[a-zA-Z\s]{1,3}$ + +### accept_patterns +- (quero falar com atendente|me transfere|cadê o supervisor|chega de robô|quero humano|falar com gente|passa pra alguém|não quero falar com robô|me passa pra|falar com uma pessoa|atendente humano|suporte humano|operador) +- (talk to a human|speak to an agent|transfer me|i want a real person|connect me to support|let me speak to someone|human agent|representative|operator|stop talking to a bot) +- (quiero hablar con|pásame con un agente|quiero un humano|operador|atención humana|no quiero hablar con un robot) + +### constraints +- min_length: 3 + +## prompt + +You are an intent classifier for human handoff detection. Given the dialogue below, determine whether the user wants to speak with a human agent. + +Rules: +1. Detect direct requests: "quero falar com atendente", "transfer me to an agent", "I want a real person". +2. Detect frustrated indirect requests: "isso não está resolvendo nada", "vocês não ajudam", "esse chat é inútil" — these imply wanting a human even without explicit request. +3. Assess urgency: high (caps, exclamation marks, repeated request, profanity), medium (direct calm request), low (polite optional request). +4. Set frustration_detected=true if the user shows anger, uses caps, or has been repeating the same request. +5. If the user does NOT want a human, return wants_human: false. + +Dialogue: +Agent: {{agent_input}} +User: {{user_input}} + +Agent context: {{agent_context}} + +Reply ONLY with valid JSON, no markdown: +{ + "extracted_value": "{\"wants_human\":true/false,\"urgency\":\"low/medium/high\",\"frustration_detected\":true/false}", + "certain": true/false, + "reasoning": "short explanation" +} + +## few_shot_examples + +### example 1 +- agent_input: How can I help? +- user_input: I want to talk to a real person +- output: {"extracted_value": "{\"wants_human\":true,\"urgency\":\"medium\",\"frustration_detected\":false}", "certain": true, "reasoning": "Direct request for human agent"} + +### example 2 +- agent_input: Let me check that for you +- user_input: ENOUGH! Transfer me to someone NOW!!! +- output: {"extracted_value": "{\"wants_human\":true,\"urgency\":\"high\",\"frustration_detected\":true}", "certain": true, "reasoning": "High urgency: caps, exclamation, explicit transfer request"} + +### example 3 +- agent_input: Posso ajudar em algo mais? +- user_input: isso não tá resolvendo nada +- output: {"extracted_value": "{\"wants_human\":true,\"urgency\":\"medium\",\"frustration_detected\":true}", "certain": false, "reasoning": "Indirect signal of frustration implying desire for human help"} + +### example 4 +- agent_input: Is there anything else I can help with? +- user_input: seria possível falar com alguém da equipe? +- output: {"extracted_value": "{\"wants_human\":true,\"urgency\":\"low\",\"frustration_detected\":false}", "certain": true, "reasoning": "Polite low-urgency request"} + +### example 5 +- agent_input: Can I assist you further? +- user_input: no you can help me, go ahead +- output: {"extracted_value": "{\"wants_human\":false,\"urgency\":\"low\",\"frustration_detected\":false}", "certain": true, "reasoning": "User explicitly declined human transfer"} + +## post_processors +- select_handoff_suggestion + +## suggestions + +### when_handoff_high +#### pt-BR +Entendo sua frustração. Deixa eu te transferir agora para um atendente humano. + +#### en-US +I understand your frustration. Let me transfer you to a human agent right away. + +#### es-ES +Entiendo tu frustración. Permíteme transferirte a un agente humano ahora mismo. + +### when_handoff_medium +#### pt-BR +Posso te transferir para um atendente humano. Você gostaria? + +#### en-US +I can transfer you to a human agent. Would you like that? + +#### es-ES +Puedo transferirte a un agente humano. ¿Te gustaría eso? + +### when_handoff_low +#### pt-BR +Se preferir falar com alguém da nossa equipe, posso providenciar isso. + +#### en-US +If you'd prefer to speak with someone from our team, I can arrange that. + +#### es-ES +Si prefieres hablar con alguien de nuestro equipo, puedo organizarlo. + +### when_not_handoff +(no suggestion — continue conversation) diff --git a/src/Nalu.Web/Validators/validate_phone_br.md b/src/Nalu.Web/Validators/validate_phone_br.md new file mode 100644 index 0000000..2257f3e --- /dev/null +++ b/src/Nalu.Web/Validators/validate_phone_br.md @@ -0,0 +1,100 @@ +# validate_phone_br + +Extrai telefone brasileiro com DDD a partir do diálogo. + +## config + +- type: extraction +- version: 1.0 +- languages: pt-BR +- endpoint: /v1/extract/phone +- mcp_tool: nalu_extract_phone +- mcp_description: Extrai o número de telefone brasileiro do usuário, incluindo DDD. Formata automaticamente. Se certain=true, o número é válido e formatado. Se obtained=false, use suggestion_to_agent para pedir novamente. + +## deterministic_rules + +### stop_words +bom dia, boa tarde, boa noite, olá, oi, não tenho, nao tenho, sem telefone + +### reject_patterns +- ^[a-zA-Z\s]+$ +- ^(não|nao|sem|nenhum)$ + +### accept_patterns +- (\+?55\s?\(?\d{2}\)?\s?\d{4,5}[-\s]?\d{4}) +- (\(?\d{2}\)?\s?\d{4,5}[-\s]?\d{4}) +- (? new ValidatorDefinition + { + Id = "validate_full_name", + StopWords = ["bom dia", "boa tarde", "boa noite", "olá", "oi"], + RejectPatterns = + [ + @"^(não|nao|sei la|sei lá|tanto faz)$", + @"^\d+$" + ], + AcceptPatterns = + [ + @"^meu nome é\s+(.+)$", + @"^me chamo\s+(.+)$" + ], + Constraints = new Dictionary + { + { "min_length", "2" }, + { "must_have_alpha", "true" }, + { "max_length", "120" } + } + }; + + // ── Stop words ──────────────────────────────────────────────────────────── + + [Theory] + [InlineData("Bom dia")] + [InlineData("bom dia")] + [InlineData("Bom dia!")] + [InlineData("Oi")] + [InlineData("olá")] + public void StopWord_ReturnsRejected(string input) + { + var result = _layer.Evaluate(NameValidator(), input); + + result.Outcome.Should().Be(DeterministicOutcome.Rejected); + result.ExtractedValue.Should().BeNull(); + } + + // ── Reject patterns ─────────────────────────────────────────────────────── + + [Theory] + [InlineData("não")] + [InlineData("sei la")] + [InlineData("tanto faz")] + [InlineData("12345")] + public void RejectPattern_ReturnsRejected(string input) + { + var result = _layer.Evaluate(NameValidator(), input); + + result.Outcome.Should().Be(DeterministicOutcome.Rejected); + } + + // ── Accept patterns ─────────────────────────────────────────────────────── + + [Theory] + [InlineData("meu nome é João Silva", "João Silva")] + [InlineData("Meu nome é Maria", "Maria")] + [InlineData("me chamo Pedro", "Pedro")] + public void AcceptPattern_ReturnsAcceptedWithCapturedGroup(string input, string expectedValue) + { + var result = _layer.Evaluate(NameValidator(), input); + + result.Outcome.Should().Be(DeterministicOutcome.Accepted); + result.ExtractedValue.Should().Be(expectedValue); + } + + // ── Unresolved ──────────────────────────────────────────────────────────── + + [Theory] + [InlineData("Carlos Alberto Ferreira")] + [InlineData("Ana")] + public void FreeText_ReturnsUnresolved(string input) + { + var result = _layer.Evaluate(NameValidator(), input); + + result.Outcome.Should().Be(DeterministicOutcome.Unresolved); + } + + // ── Constraints ─────────────────────────────────────────────────────────── + + [Fact] + public void AcceptPattern_WhenValueTooShort_ReturnsConstraintFailed() + { + var validator = NameValidator(); + validator.Constraints["min_length"] = "5"; + + var result = _layer.Evaluate(validator, "meu nome é Al"); + + result.Outcome.Should().Be(DeterministicOutcome.ConstraintFailed); + result.Reasoning.Should().Contain("mínimo"); + } + + [Fact] + public void AcceptPattern_WhenValueHasNoLetters_ReturnsConstraintFailed() + { + var result = _layer.Evaluate(NameValidator(), "meu nome é 999"); + + result.Outcome.Should().Be(DeterministicOutcome.ConstraintFailed); + result.Reasoning.Should().Contain("letras"); + } + + [Fact] + public void AcceptPattern_WhenValueTooLong_ReturnsConstraintFailed() + { + var longName = new string('A', 121); + var result = _layer.Evaluate(NameValidator(), $"meu nome é {longName}"); + + result.Outcome.Should().Be(DeterministicOutcome.ConstraintFailed); + result.Reasoning.Should().Contain("máximo"); + } + + // ── No patterns configured ──────────────────────────────────────────────── + + [Fact] + public void EmptyValidator_AlwaysUnresolved() + { + var validator = new ValidatorDefinition { Id = "empty" }; + var result = _layer.Evaluate(validator, "qualquer coisa"); + + result.Outcome.Should().Be(DeterministicOutcome.Unresolved); + } +} diff --git a/tests/Nalu.Tests/McpServerTests.cs b/tests/Nalu.Tests/McpServerTests.cs new file mode 100644 index 0000000..b808a81 --- /dev/null +++ b/tests/Nalu.Tests/McpServerTests.cs @@ -0,0 +1,199 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace Nalu.Tests; + +public class McpServerTests : IClassFixture> +{ + private readonly HttpClient _client; + + public McpServerTests(WebApplicationFactory factory) + { + _client = factory.CreateClient(); + _client.DefaultRequestHeaders.Add("Authorization", "Bearer nalu-test-key-001"); + } + + private async Task PostMcp(object body) + { + var json = JsonSerializer.Serialize(body); + var response = await _client.PostAsync("/mcp", + new StringContent(json, Encoding.UTF8, "application/json")); + response.EnsureSuccessStatusCode(); + var text = await response.Content.ReadAsStringAsync(); + return JsonNode.Parse(text); + } + + // ── Auth ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Mcp_NoApiKey_Returns401() + { + using var client = new HttpClient { BaseAddress = _client.BaseAddress }; + var response = await client.PostAsync("/mcp", + new StringContent("{}", Encoding.UTF8, "application/json")); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + // ── initialize ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Mcp_Initialize_ReturnsServerInfo() + { + var result = await PostMcp(new + { + jsonrpc = "2.0", + method = "initialize", + @params = new { protocolVersion = "2024-11-05", capabilities = new { } }, + id = 1 + }); + + result.Should().NotBeNull(); + result!["result"]!["serverInfo"]!["name"]!.GetValue().Should().Be("nalu-ai"); + result["result"]!["protocolVersion"]!.GetValue().Should().Be("2024-11-05"); + result["result"]!["capabilities"]!["tools"].Should().NotBeNull(); + } + + // ── tools/list ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Mcp_ToolsList_ContainsAllValidators() + { + var result = await PostMcp(new + { + jsonrpc = "2.0", + method = "tools/list", + @params = new { }, + id = 2 + }); + + var tools = result!["result"]!["tools"]!.AsArray(); + tools.Should().NotBeEmpty(); + + var toolNames = tools.Select(t => t!["name"]!.GetValue()).ToList(); + toolNames.Should().Contain("nalu_extract_name"); + toolNames.Should().Contain("nalu_extract_cpf"); + toolNames.Should().Contain("nalu_extract_cep"); + toolNames.Should().Contain("nalu_extract_phone"); + toolNames.Should().Contain("nalu_extract_email"); + toolNames.Should().Contain("nalu_extract_yes_no"); + toolNames.Should().Contain("nalu_extract_postal_code"); + // Universal — new + toolNames.Should().Contain("nalu_extract_birthdate"); + toolNames.Should().Contain("nalu_extract_handoff"); + toolNames.Should().Contain("nalu_extract_cancel_intent"); + // Brasil — new + toolNames.Should().Contain("nalu_extract_cnpj"); + toolNames.Should().Contain("nalu_extract_plate_br"); + toolNames.Should().Contain("nalu_extract_company_name"); + } + + [Fact] + public async Task Mcp_ToolsList_EachToolHasInputSchema() + { + var result = await PostMcp(new + { + jsonrpc = "2.0", + method = "tools/list", + @params = new { }, + id = 3 + }); + + var tools = result!["result"]!["tools"]!.AsArray(); + foreach (var tool in tools) + { + var schema = tool!["inputSchema"]; + schema.Should().NotBeNull($"tool {tool["name"]} should have inputSchema"); + schema!["properties"]!["agent_input"].Should().NotBeNull(); + schema["properties"]!["user_input"].Should().NotBeNull(); + var required = schema["required"]!.AsArray().Select(r => r!.GetValue()).ToList(); + required.Should().Contain("agent_input"); + required.Should().Contain("user_input"); + } + } + + // ── tools/call ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Mcp_ToolsCall_ExtractName_WithGreeting_ObtainedFalse() + { + var result = await PostMcp(new + { + jsonrpc = "2.0", + method = "tools/call", + @params = new + { + name = "nalu_extract_name", + arguments = new + { + agent_input = "Qual seu nome completo?", + user_input = "Bom dia!" + } + }, + id = 4 + }); + + result!["result"]!["isError"]!.GetValue().Should().BeFalse(); + + var content = result["result"]!["content"]!.AsArray(); + content.Should().HaveCount(1); + content[0]!["type"]!.GetValue().Should().Be("text"); + + var text = content[0]!["text"]!.GetValue(); + var extracted = JsonSerializer.Deserialize>(text)!; + extracted["obtained"].GetBoolean().Should().BeFalse(); + } + + [Fact] + public async Task Mcp_ToolsCall_UnknownTool_ReturnsError() + { + var result = await PostMcp(new + { + jsonrpc = "2.0", + method = "tools/call", + @params = new { name = "nalu_extract_unknown", arguments = new { } }, + id = 5 + }); + + result!["error"].Should().NotBeNull(); + result["error"]!["code"]!.GetValue().Should().Be(-32602); + } + + // ── New .md file → auto-appears in tools/list ───────────────────────────── + + [Fact] + public async Task Mcp_ToolsList_AllLoadedValidatorsHaveMcpTool() + { + // Any validator without mcp_tool should not appear in tools/list + var result = await PostMcp(new + { + jsonrpc = "2.0", + method = "tools/list", + @params = new { }, + id = 6 + }); + + var tools = result!["result"]!["tools"]!.AsArray(); + foreach (var tool in tools) + { + tool!["name"]!.GetValue().Should().NotBeNullOrWhiteSpace(); + tool!["description"]!.GetValue().Should().NotBeNullOrWhiteSpace(); + } + } + + // ── notifications ───────────────────────────────────────────────────────── + + [Fact] + public async Task Mcp_Initialized_Notification_Returns202() + { + var json = JsonSerializer.Serialize(new { jsonrpc = "2.0", method = "initialized" }); + var response = await _client.PostAsync("/mcp", + new StringContent(json, Encoding.UTF8, "application/json")); + response.StatusCode.Should().Be(HttpStatusCode.Accepted); + } +} diff --git a/tests/Nalu.Tests/Nalu.Tests.csproj b/tests/Nalu.Tests/Nalu.Tests.csproj new file mode 100644 index 0000000..54b15a1 --- /dev/null +++ b/tests/Nalu.Tests/Nalu.Tests.csproj @@ -0,0 +1,24 @@ + + + net9.0 + enable + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/tests/Nalu.Tests/NewValidatorsTests.cs b/tests/Nalu.Tests/NewValidatorsTests.cs new file mode 100644 index 0000000..2ac69eb --- /dev/null +++ b/tests/Nalu.Tests/NewValidatorsTests.cs @@ -0,0 +1,168 @@ +using FluentAssertions; +using Nalu.Api.PostProcessors; +using Nalu.Api.Services; +using Xunit; + +namespace Nalu.Tests; + +// ── ValidateCpfDigit ────────────────────────────────────────────────────────── + +public class ValidateCpfDigitTests +{ + private readonly ValidateCpfDigit _sut = new(); + + [Theory] + [InlineData("529.982.247-25", "529.982.247-25")] // valid CPF + [InlineData("52998224725", "529.982.247-25")] // no formatting + [InlineData("529 982 247 25", "529.982.247-25")] // spaces + public void ValidCpf_ReturnsFormattedValue(string input, string expected) + { + var result = _sut.Process(input); + + result.IsValid.Should().BeTrue(); + result.Value.Should().Be(expected); + } + + [Theory] + [InlineData("111.111.111-11")] // all same digit + [InlineData("000.000.000-00")] + [InlineData("123.456.789-00")] // wrong check digits + [InlineData("1234567890")] // too few digits + [InlineData("123456789012")] // too many digits + public void InvalidCpf_ReturnsInvalid(string input) + { + var result = _sut.Process(input); + + result.IsValid.Should().BeFalse(); + result.InvalidReason.Should().NotBeNullOrEmpty(); + } +} + +// ── FormatPhone ─────────────────────────────────────────────────────────────── + +public class FormatPhoneTests +{ + private readonly FormatPhone _sut = new(); + + [Theory] + [InlineData("11999998888", "(11) 99999-8888")] // mobile, no formatting + [InlineData("(11) 99999-8888", "(11) 99999-8888")] // already formatted + [InlineData("+55 11 99999-8888", "(11) 99999-8888")] // with country code + [InlineData("1134567890", "(11) 3456-7890")] // landline + public void ValidPhone_ReturnsFormatted(string input, string expected) + { + var result = _sut.Process(input); + + result.IsValid.Should().BeTrue(); + result.Value.Should().Be(expected); + } + + [Theory] + [InlineData("9999")] // too short + [InlineData("999999999999999")] // too long + [InlineData("1190909090909")] // 13 digits — no valid Brazilian format + [InlineData("23999998888")] // invalid DDD 23 + [InlineData("36999998888")] // invalid DDD 36 + [InlineData("52999998888")] // invalid DDD 52 + public void InvalidPhone_ReturnsInvalid(string input) + { + var result = _sut.Process(input); + + result.IsValid.Should().BeFalse(); + } +} + +// ── FormatCep ───────────────────────────────────────────────────────────────── + +public class FormatCepTests +{ + private readonly FormatCep _sut = new(); + + [Theory] + [InlineData("01001000", "01001-000")] + [InlineData("01001-000", "01001-000")] + [InlineData("04538133", "04538-133")] + public void ValidCep_ReturnsFormatted(string input, string expected) + { + var result = _sut.Process(input); + + result.IsValid.Should().BeTrue(); + result.Value.Should().Be(expected); + } + + [Theory] + [InlineData("0000000")] // 7 digits + [InlineData("000000000")] // 9 digits + [InlineData("00000000")] // all zeros + public void InvalidCep_ReturnsInvalid(string input) + { + var result = _sut.Process(input); + + result.IsValid.Should().BeFalse(); + } +} + +// ── CorrectEmailTypos ───────────────────────────────────────────────────────── + +public class CorrectEmailTyposTests +{ + private readonly CorrectEmailTypos _sut = new(); + + [Theory] + [InlineData("user@gamil.com", "user@gmail.com", "user@gamil.com")] + [InlineData("user@gmaill.com", "user@gmail.com", "user@gmaill.com")] + [InlineData("user@hotmal.com", "user@hotmail.com", "user@hotmal.com")] + [InlineData("user@outlok.com", "user@outlook.com", "user@outlok.com")] + [InlineData("user@yaho.com", "user@yahoo.com", "user@yaho.com")] + public void TypoDomain_ReturnsCorrected(string input, string expectedValue, string expectedOriginal) + { + var result = _sut.Process(input); + + result.IsValid.Should().BeTrue(); + result.WasCorrected.Should().BeTrue(); + result.Value.Should().Be(expectedValue); + result.OriginalValue.Should().Be(expectedOriginal); + } + + [Theory] + [InlineData("user@gmail.com")] + [InlineData("user@hotmail.com")] + [InlineData("user@company.com.br")] + public void CorrectDomain_ReturnsOk_NotCorrected(string input) + { + var result = _sut.Process(input); + + result.IsValid.Should().BeTrue(); + result.WasCorrected.Should().BeFalse(); + } +} + +// ── DeterministicLayer — new validators ─────────────────────────────────────── + +public class CpfDeterministicTests +{ + private readonly DeterministicLayer _layer = new(); + + [Fact] + public void CpfPattern_ExtractsDigits() + { + var validator = ValidatorLoader.ParseFromContent("validate_cpf", + System.IO.File.ReadAllText("../../../../../src/Nalu.Api/Validators/validate_cpf.md")); + + var result = _layer.Evaluate(validator, "123.456.789-09"); + + result.Outcome.Should().Be(DeterministicOutcome.Accepted); + result.ExtractedValue.Should().Contain("12345678909"); + } + + [Fact] + public void CpfEvasive_ReturnsRejected() + { + var validator = ValidatorLoader.ParseFromContent("validate_cpf", + System.IO.File.ReadAllText("../../../../../src/Nalu.Api/Validators/validate_cpf.md")); + + var result = _layer.Evaluate(validator, "não lembro"); + + result.Outcome.Should().Be(DeterministicOutcome.Rejected); + } +} diff --git a/tests/Nalu.Tests/PipelineIntegrationTests.cs b/tests/Nalu.Tests/PipelineIntegrationTests.cs new file mode 100644 index 0000000..7d3081e --- /dev/null +++ b/tests/Nalu.Tests/PipelineIntegrationTests.cs @@ -0,0 +1,136 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Nalu.Api.Models; +using Xunit; + +namespace Nalu.Tests; + +/// +/// Integration tests using WebApplicationFactory. +/// The Validators directory must be available in the test output — the .csproj +/// CopyToOutputDirectory on the main project handles this via project reference. +/// +public class PipelineIntegrationTests : IClassFixture> +{ + private readonly HttpClient _client; + + public PipelineIntegrationTests(WebApplicationFactory factory) + { + _client = factory.CreateClient(); + _client.DefaultRequestHeaders.Add("Authorization", "Bearer nalu-test-key-001"); + } + + // ── Auth ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task NoApiKey_Returns401() + { + using var client = new HttpClient { BaseAddress = _client.BaseAddress }; + var response = await client.PostAsJsonAsync("/v1/extract/name", new + { + agent_input = "Qual seu nome?", + user_input = "João" + }); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task InvalidApiKey_Returns401() + { + using var client = new HttpClient { BaseAddress = _client.BaseAddress }; + client.DefaultRequestHeaders.Add("Authorization", "Bearer invalid-key"); + var response = await client.PostAsJsonAsync("/v1/extract/name", new + { + agent_input = "Qual seu nome?", + user_input = "João" + }); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + // ── Name extraction — deterministic path ────────────────────────────────── + + [Fact] + public async Task PostName_WithGreeting_ReturnsObtainedFalse() + { + var response = await PostExtractName("Qual seu nome completo?", "Bom dia!"); + + response.Should().NotBeNull(); + response!.Obtained.Should().BeFalse(); + response.Certain.Should().BeFalse(); + response.ExtractedValue.Should().BeNull(); + response.SuggestionToAgent.Should().Contain("Bom dia!"); + } + + [Fact] + public async Task PostName_WithAcceptPattern_ReturnsObtainedTrue() + { + var response = await PostExtractName("Qual seu nome?", "meu nome é Carlos Silva"); + + response.Should().NotBeNull(); + response!.Obtained.Should().BeTrue(); + response.Certain.Should().BeTrue(); + response.ExtractedValue.Should().Be("Carlos Silva"); + response.Confidence.Should().Be("high"); + } + + [Fact] + public async Task PostName_RejectPattern_ReturnsObtainedFalse() + { + var response = await PostExtractName("Qual seu nome?", "sei la"); + + response.Should().NotBeNull(); + response!.Obtained.Should().BeFalse(); + } + + // ── GET /v1/validators ──────────────────────────────────────────────────── + + [Fact] + public async Task GetValidators_ReturnsValidatorList() + { + var result = await _client.GetFromJsonAsync("/v1/validators"); + + result.Should().NotBeNull(); + result!.Validators.Should().NotBeEmpty(); + result.Validators.Should().Contain(v => v.Id == "validate_full_name"); + + var nameValidator = result.Validators.First(v => v.Id == "validate_full_name"); + nameValidator.Endpoint.Should().Be("/v1/extract/name"); + nameValidator.McpTool.Should().Be("nalu_extract_name"); + nameValidator.Languages.Should().Contain("pt-BR"); + } + + // ── Response structure ──────────────────────────────────────────────────── + + [Fact] + public async Task PostName_ResponseContainsAllRequiredFields() + { + var response = await PostExtractName("Qual seu nome?", "Bom dia!"); + + response.Should().NotBeNull(); + response!.Obtained.Should().BeFalse(); // greeting → not obtained + response.Confidence.Should().BeOneOf("high", "medium", "low"); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task PostExtractName(string agentInput, string userInput) + { + var payload = new ExtractionRequest + { + AgentInput = agentInput, + UserInput = userInput, + Language = "pt-BR" + }; + + var httpResponse = await _client.PostAsJsonAsync("/v1/extract/name", payload); + httpResponse.EnsureSuccessStatusCode(); + return await httpResponse.Content.ReadFromJsonAsync(); + } + + private record ValidatorsResponse(List Validators); +} diff --git a/tests/Nalu.Tests/ValidatorLoaderTests.cs b/tests/Nalu.Tests/ValidatorLoaderTests.cs new file mode 100644 index 0000000..1389ee3 --- /dev/null +++ b/tests/Nalu.Tests/ValidatorLoaderTests.cs @@ -0,0 +1,198 @@ +using FluentAssertions; +using Nalu.Api.Services; +using Xunit; + +namespace Nalu.Tests; + +public class ValidatorLoaderTests +{ + private const string FullNameMd = """ + # validate_full_name + + Extrai o nome completo do usuário. + + ## 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. + + ## deterministic_rules + + ### stop_words + bom dia, boa tarde, boa noite, olá, oi + + ### reject_patterns + - ^(não|nao|sei la)$ + - ^\d+$ + + ### accept_patterns + - ^meu nome é\s+(.+)$ + - ^me chamo\s+(.+)$ + + ### constraints + - min_length: 2 + - must_have_alpha: true + - max_length: 120 + + ## prompt + + Você é um extrator de nomes. + + Diálogo: + Agente: {{agent_input}} + Usuário: {{user_input}} + + Contexto do agente: {{agent_context}} + + Responda SOMENTE com JSON válido. + + ## few_shot_examples + + ### example 1 + - agent_input: Qual seu nome? + - user_input: Bom dia! + - output: {"extracted_value": null, "certain": false, "reasoning": "Saudação"} + + ### example 2 + - agent_input: Qual seu nome? + - user_input: Maria Silva + - output: {"extracted_value": "Maria Silva", "certain": true, "reasoning": "Nome plausível"} + + ## post_processors + - capitalize_proper_name + - remove_titles + + ## enrichment + (nenhum) + + ## suggestions + + ### when_null_greeting + {{greeting_response}} Mas preciso do seu nome. Pode me dizer? + + ### when_null_evasive + Preciso do seu nome para continuar. Qual é? + + ### when_uncertain + Só confirmando: seu nome é {{extracted_value}}? + + ### when_certain + (sem sugestão — agente segue o fluxo) + """; + + [Fact] + public void Parse_Config_ReadsAllFields() + { + var def = ValidatorLoader.ParseFromContent("validate_full_name", FullNameMd); + + def.Id.Should().Be("validate_full_name"); + def.Type.Should().Be("extraction"); + def.Version.Should().Be("1.0"); + def.Languages.Should().BeEquivalentTo(["pt-BR", "es-ES", "en-US"]); + def.Endpoint.Should().Be("/v1/extract/name"); + def.McpTool.Should().Be("nalu_extract_name"); + def.McpDescription.Should().Contain("Extrai o nome completo"); + } + + [Fact] + public void Parse_StopWords_ParsesCommaSeparated() + { + var def = ValidatorLoader.ParseFromContent("validate_full_name", FullNameMd); + + def.StopWords.Should().Contain("bom dia"); + def.StopWords.Should().Contain("boa tarde"); + def.StopWords.Should().Contain("oi"); + def.StopWords.Should().HaveCount(5); + } + + [Fact] + public void Parse_RejectPatterns_ParsesBulletList() + { + var def = ValidatorLoader.ParseFromContent("validate_full_name", FullNameMd); + + def.RejectPatterns.Should().HaveCount(2); + def.RejectPatterns[0].Should().Be(@"^(não|nao|sei la)$"); + def.RejectPatterns[1].Should().Be(@"^\d+$"); + } + + [Fact] + public void Parse_AcceptPatterns_ParsesBulletList() + { + var def = ValidatorLoader.ParseFromContent("validate_full_name", FullNameMd); + + def.AcceptPatterns.Should().HaveCount(2); + def.AcceptPatterns[0].Should().Be(@"^meu nome é\s+(.+)$"); + } + + [Fact] + public void Parse_Constraints_ParsesKeyValues() + { + var def = ValidatorLoader.ParseFromContent("validate_full_name", FullNameMd); + + def.Constraints.Should().ContainKey("min_length").WhoseValue.Should().Be("2"); + def.Constraints.Should().ContainKey("max_length").WhoseValue.Should().Be("120"); + def.Constraints.Should().ContainKey("must_have_alpha").WhoseValue.Should().Be("true"); + } + + [Fact] + public void Parse_Prompt_CapturesFullText() + { + var def = ValidatorLoader.ParseFromContent("validate_full_name", FullNameMd); + + def.Prompt.Should().Contain("Você é um extrator de nomes"); + def.Prompt.Should().Contain("{{agent_input}}"); + def.Prompt.Should().Contain("{{user_input}}"); + } + + [Fact] + public void Parse_FewShots_ParsesAllExamples() + { + var def = ValidatorLoader.ParseFromContent("validate_full_name", FullNameMd); + + def.FewShotExamples.Should().HaveCount(2); + def.FewShotExamples[0].AgentInput.Should().Be("Qual seu nome?"); + def.FewShotExamples[0].UserInput.Should().Be("Bom dia!"); + def.FewShotExamples[1].UserInput.Should().Be("Maria Silva"); + } + + [Fact] + public void Parse_PostProcessors_ParsesBulletList() + { + var def = ValidatorLoader.ParseFromContent("validate_full_name", FullNameMd); + + def.PostProcessors.Should().BeEquivalentTo(["capitalize_proper_name", "remove_titles"]); + } + + [Fact] + public void Parse_Enrichment_NenhumYieldsEmptyList() + { + var def = ValidatorLoader.ParseFromContent("validate_full_name", FullNameMd); + + def.Enrichers.Should().BeEmpty(); + } + + [Fact] + public void Parse_Suggestions_ParsesTemplates() + { + var def = ValidatorLoader.ParseFromContent("validate_full_name", FullNameMd); + + def.Suggestions.Should().ContainKey("when_null_greeting"); + def.Suggestions.Should().ContainKey("when_null_evasive"); + def.Suggestions.Should().ContainKey("when_uncertain"); + // when_certain has "(sem sugestão..." so it should NOT be stored + def.Suggestions.Should().NotContainKey("when_certain"); + } + + [Fact] + public void Parse_Suggestions_ContainsPlaceholders() + { + var def = ValidatorLoader.ParseFromContent("validate_full_name", FullNameMd); + + def.Suggestions["when_null_greeting"].Should().Contain("{{greeting_response}}"); + def.Suggestions["when_uncertain"].Should().Contain("{{extracted_value}}"); + } +}