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 <noreply@anthropic.com>
This commit is contained in:
Ricardo Carneiro 2026-05-10 16:39:04 -03:00
commit ea6cdb5395
249 changed files with 19861 additions and 0 deletions

View File

@ -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:*)"
]
}
}

73
.github/workflows/deploy.yml vendored Normal file
View File

@ -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

79
.gitignore vendored Normal file
View File

@ -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/

40
CLAUDE.md Normal file
View File

@ -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 <file>`
- 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 <file>` 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.cs> # 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`.

26
Dockerfile Normal file
View File

@ -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"]

25
Nalu.sln Normal file
View File

@ -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

838
Prompt1.md Normal file
View File

@ -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
{
&#x20; "agent\_input": "Bom dia! Qual seu nome?",
&#x20; "user\_input": "Bom dia!",
&#x20; "agent\_context": "Negociador de parcelamento.",
&#x20; "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)
&#x20;
Auth (API key via header Authorization: Bearer {key})
&#x20;
Rate limit por plano
&#x20;
Resolve qual validador (.md) usar baseado no endpoint
&#x20;
Cache lookup (hash de: validator\_id + agent\_input + user\_input + language)
&#x20; ↓ cache miss
Carregar validador (.md do disco, com cache em memória)
&#x20;
Camada 1 — Regras determinísticas (regex, stop words, constraints)
&#x20; ↓ não resolveu
Camada 2 — LLM com prompt do validador (Groq → fallback OpenRouter)
&#x20;
Pós-processamento (normalização, dígito verificador, etc.)
&#x20;
Enriquecimento externo (ViaCEP etc., se o validador declarar)
&#x20;
Montar sugestão de fala (template do validador)
&#x20;
Salvar log + retornar resposta
```
\---
\## Estrutura de pastas
```
src/
&#x20; Nalu.Api/
&#x20; Program.cs
&#x20; appsettings.json
&#x20; appsettings.Development.json
&#x20; Endpoints/
&#x20; ExtractEndpoints.cs # Registra todos os POST /v1/extract/{tipo}
&#x20; ValidatorsEndpoints.cs # GET /v1/validators
&#x20; Models/
&#x20; ExtractionRequest.cs # Body compartilhado por todos os endpoints
&#x20; ExtractionResponse.cs # Response compartilhada
&#x20; ValidatorInfo.cs # Modelo para GET /v1/validators
&#x20; ValidatorDefinition.cs # Validador parseado do .md
&#x20; Services/
&#x20; ExtractionPipeline.cs # Orquestra o pipeline (recebe validator\_id)
&#x20; ValidatorLoader.cs # Parseia .md, cache em memória, FileSystemWatcher
&#x20; DeterministicLayer.cs # Camada 1: stop words, regex, constraints
&#x20; LlmExtractionService.cs # Camada 2: Groq/OpenRouter
&#x20; PostProcessorRegistry.cs # Registry de IPostProcessor por nome
&#x20; EnrichmentService.cs # Orquestra enrichers
&#x20; SuggestionBuilder.cs # Monta suggestion\_to\_agent
&#x20; CacheService.cs # IMemoryCache com hash
&#x20; AuthService.cs # Valida API key, retorna plano
&#x20; RateLimitService.cs # Rate limit por plano
&#x20; PostProcessors/
&#x20; IPostProcessor.cs
&#x20; CapitalizeProperName.cs
&#x20; RemoveTitles.cs
&#x20; Enrichers/
&#x20; IEnricher.cs
&#x20; Infrastructure/
&#x20; GroqClient.cs
&#x20; OpenRouterClient.cs
&#x20; NaluDbContext.cs
&#x20; Validators/ # ← CADA .md É UM VALIDADOR
&#x20; validate\_full\_name.md
tests/
&#x20; Nalu.Tests/
&#x20; ValidatorLoaderTests.cs
&#x20; DeterministicLayerTests.cs
&#x20; PipelineIntegrationTests.cs
Dockerfile
```
\### Registro dos endpoints (Minimal APIs)
```csharp
// ExtractEndpoints.cs
public static class ExtractEndpoints
{
&#x20; public static void MapExtractEndpoints(this WebApplication app)
&#x20; {
&#x20; var group = app.MapGroup("/v1/extract")
&#x20; .RequireAuthorization();
&#x20; group.MapPost("/name", async (ExtractionRequest req, ExtractionPipeline pipeline) =>
&#x20; await pipeline.ExecuteAsync("validate\_full\_name", req));
&#x20; // Prompt 2 adiciona os demais aqui — mesma estrutura, 2 linhas cada
&#x20; }
}
```
\---
\## 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:
{
&#x20; "extracted\_value": "nome extraído ou null",
&#x20; "certain": true/false,
&#x20; "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
{
&#x20; "agent\_input": "Bom dia! Qual seu nome completo?",
&#x20; "user\_input": "Bom dia!",
&#x20; "agent\_context": "Negociador de parcelamento. Precisa de nome e CPF.",
&#x20; "language": "pt-BR"
}
```
\### Response (mesma estrutura para todos)
```json
{
&#x20; "obtained": false,
&#x20; "extracted\_value": null,
&#x20; "confidence": 0.15,
&#x20; "certain": false,
&#x20; "reasoning": "Usuário respondeu com saudação, não informou o nome",
&#x20; "suggestion\_to\_agent": "Bom dia! Mas preciso do seu nome completo para continuar. Pode me dizer?",
&#x20; "validator\_used": "validate\_full\_name",
&#x20; "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
{
&#x20; "validators": \[
&#x20; {
&#x20; "id": "validate\_full\_name",
&#x20; "endpoint": "/v1/extract/name",
&#x20; "mcp\_tool": "nalu\_extract\_name",
&#x20; "description": "Extrai o nome completo do usuário",
&#x20; "version": "1.0",
&#x20; "languages": \["pt-BR", "es-ES", "en-US"]
&#x20; }
&#x20; ]
}
```
\---
\## 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
{
&#x20; "Groq": {
&#x20; "ApiKey": "YOUR\_GROQ\_API\_KEY",
&#x20; "BaseUrl": "https://api.groq.com/openai/v1",
&#x20; "Model": "llama-3.3-70b-versatile",
&#x20; "MaxTokens": 500,
&#x20; "Temperature": 0.1
&#x20; },
&#x20; "OpenRouter": {
&#x20; "ApiKey": "YOUR\_OPENROUTER\_API\_KEY",
&#x20; "BaseUrl": "https://openrouter.ai/api/v1",
&#x20; "Model": "mistralai/mistral-7b-instruct",
&#x20; "MaxTokens": 500,
&#x20; "Temperature": 0.1
&#x20; },
&#x20; "Plans": {
&#x20; "free": { "monthly\_limit": 2000, "daily\_limit": 100 },
&#x20; "hobby": { "monthly\_limit": 5000, "daily\_limit": null },
&#x20; "indie": { "monthly\_limit": 25000, "daily\_limit": null },
&#x20; "pro": { "monthly\_limit": 100000, "daily\_limit": null }
&#x20; },
&#x20; "Cache": { "DefaultTtlMinutes": 60 },
&#x20; "ApiKeys": \[
&#x20; { "key": "nalu-test-key-001", "plan": "free", "owner": "test" }
&#x20; ]
}
```
\### 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

242
Prompt2.md Normal file
View File

@ -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

55
docker-compose.yml Normal file
View File

@ -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

130
docs/site-brief.md Normal file
View File

@ -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]** — `<img src="/images/comic-1-pt.webp">`
5. Validator catalog grid (Universal 7 + Brasil 5)
6. State machine section — how validators chain into flows
7. **[COMIC PLACEHOLDER 2]** — `<img src="/images/comic-2-pt.webp">`
8. Code snippets (tabs: cURL | JS | C# | Python | n8n)
9. Pricing summary (3 cols)
10. FAQ (5 questions)
11. **[COMIC PLACEHOLDER 3 — optional]** — `<img src="/images/comic-3-pt.webp">`
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)

10
mcp-config.json Normal file
View File

@ -0,0 +1,10 @@
{
"mcpServers": {
"nalu": {
"url": "http://localhost:5282/mcp",
"headers": {
"Authorization": "Bearer nalu-test-key-001"
}
}
}
}

View File

@ -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; }
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,33 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Nalu.Web.Data.Models;
/// <summary>
/// One document per (api_key + date). Counters updated atomically via $inc.
/// </summary>
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"
}

View File

@ -0,0 +1,42 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Nalu.Web.Data.Models;
/// <summary>
/// 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.
/// </summary>
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<string, int> CreditsByValidator { get; set; } = new();
/// validator_id → request count
[BsonElement("requests_by_validator")]
public Dictionary<string, int> RequestsByValidator { get; set; } = new();
[BsonElement("updated_at")]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,27 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Nalu.Web.Data.Models;
/// <summary>
/// Stripe webhook idempotency table. One document per Stripe event ID.
/// </summary>
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;
}

View File

@ -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<NaluUser> Users =>
_database!.GetCollection<NaluUser>("users");
public IMongoCollection<ApiKey> ApiKeys =>
_database!.GetCollection<ApiKey>("api_keys");
public IMongoCollection<Subscription> Subscriptions =>
_database!.GetCollection<Subscription>("subscriptions");
public IMongoCollection<UsageDaily> UsageDaily =>
_database!.GetCollection<UsageDaily>("usage_daily");
public IMongoCollection<WebhookEvent> WebhookEvents =>
_database!.GetCollection<WebhookEvent>("webhook_events");
public IMongoCollection<UsageMonthly> UsageMonthly =>
_database!.GetCollection<UsageMonthly>("usage_monthly");
public async Task InitializeAsync()
{
if (!IsConnected) return;
await CreateIndexesAsync();
}
private async Task CreateIndexesAsync()
{
// users
await Users.Indexes.CreateManyAsync([
new CreateIndexModel<NaluUser>(
Builders<NaluUser>.IndexKeys.Ascending(u => u.Email),
new CreateIndexOptions { Unique = true }),
new CreateIndexModel<NaluUser>(
Builders<NaluUser>.IndexKeys
.Ascending(u => u.Provider)
.Ascending(u => u.ProviderId),
new CreateIndexOptions { Unique = true }),
]);
// api_keys
await ApiKeys.Indexes.CreateManyAsync([
new CreateIndexModel<ApiKey>(
Builders<ApiKey>.IndexKeys.Ascending(k => k.Key),
new CreateIndexOptions { Unique = true }),
new CreateIndexModel<ApiKey>(
Builders<ApiKey>.IndexKeys.Ascending(k => k.UserId)),
]);
// subscriptions
await Subscriptions.Indexes.CreateManyAsync([
new CreateIndexModel<Subscription>(
Builders<Subscription>.IndexKeys.Ascending(s => s.StripeSubscriptionId),
new CreateIndexOptions { Unique = true }),
new CreateIndexModel<Subscription>(
Builders<Subscription>.IndexKeys.Ascending(s => s.UserId)),
]);
// usage_daily — compound (api_key + date) unique for atomic $inc upserts
await UsageDaily.Indexes.CreateManyAsync([
new CreateIndexModel<UsageDaily>(
Builders<UsageDaily>.IndexKeys
.Ascending(u => u.ApiKey)
.Ascending(u => u.Date),
new CreateIndexOptions { Unique = true }),
new CreateIndexModel<UsageDaily>(
Builders<UsageDaily>.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<WebhookEvent>(
Builders<WebhookEvent>.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<UsageMonthly>(
Builders<UsageMonthly>.IndexKeys
.Ascending(u => u.ApiKey)
.Ascending(u => u.YearMonth),
new CreateIndexOptions { Unique = true }),
]);
}
}

View File

@ -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<ApiKey?> 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<ApiKey>.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<List<ApiKey>> 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<ApiKey>.Update.Set(k => k.IsActive, false),
cancellationToken: ct);
}
}

View File

@ -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<Subscription?> 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<Subscription?> 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<Subscription>.Update
.Set(s => s.Status, status)
.Set(s => s.UpdatedAt, DateTime.UtcNow),
cancellationToken: ct);
}
}

View File

@ -0,0 +1,68 @@
using MongoDB.Driver;
using Nalu.Web.Data.Models;
namespace Nalu.Web.Data.Repositories;
public class UsageRepository(MongoDbContext db)
{
/// <summary>
/// Atomically increments daily and monthly counters.
/// Returns (dailyCount, monthlyCount) AFTER increment.
/// Returns null if MongoDB is not connected.
/// </summary>
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<UsageDaily>.Filter.And(
Builders<UsageDaily>.Filter.Eq(u => u.ApiKey, apiKey),
Builders<UsageDaily>.Filter.Eq(u => u.Date, date));
var update = Builders<UsageDaily>.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<UsageDaily>
{
IsUpsert = true,
ReturnDocument = ReturnDocument.After
};
var doc = await db.UsageDaily.FindOneAndUpdateAsync(filter, update, options, ct);
return (doc.DailyCount, doc.MonthlyCount);
}
/// <summary>
/// Returns (dailyCount, monthlyCount) for the current period without modifying.
/// </summary>
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));
}
}

View File

@ -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<NaluUser?> 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<NaluUser?> 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<NaluUser?> 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<NaluUser>.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<NaluUser>.Update.Set(u => u.StripeCustomerId, stripeCustomerId),
cancellationToken: ct);
}
}

View File

@ -0,0 +1,40 @@
using MongoDB.Driver;
using Nalu.Web.Data.Models;
namespace Nalu.Web.Data.Repositories;
public class WebhookEventRepository(MongoDbContext db)
{
/// <summary>
/// Attempts to record a Stripe event for idempotency.
/// Returns true if inserted (first time seen), false if already processed.
/// </summary>
public async Task<bool> 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<bool> ExistsAsync(string stripeEventId, CancellationToken ct = default)
{
if (!db.IsConnected) return false;
return await db.WebhookEvents
.Find(w => w.StripeEventId == stripeEventId)
.AnyAsync(ct);
}
}

View File

@ -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<ExtractionResponse>().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<ExtractionResponse>().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<ExtractionResponse>().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<ExtractionResponse>().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<ExtractionResponse>().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<ExtractionResponse>().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<ExtractionResponse>().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<ExtractionResponse>().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<ExtractionResponse>().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<ExtractionResponse>().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<ExtractionResponse>().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<ExtractionResponse>().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<ExtractionResponse>().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<ReplyResponse>().ProducesProblem(429).ProducesProblem(503).WithOpenApi();
}
}

View File

@ -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();
}
}

View File

@ -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<EnrichmentResult> EnrichAsync(string value, CancellationToken ct = default);
}

View File

@ -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<ViaCepEnricher> _logger;
public string Name => "viacep";
public ViaCepEnricher(HttpClient http, ILogger<ViaCepEnricher> logger)
{
_http = http;
_logger = logger;
}
public async Task<EnrichmentResult> 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<JsonObject>(
$"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<string?>() == "true" || (erroNode.GetValueKind() == System.Text.Json.JsonValueKind.True));
if (!hasError && json != null)
return BuildResult(cep, json["logradouro"]?.GetValue<string?>(),
json["bairro"]?.GetValue<string?>(),
json["localidade"]?.GetValue<string?>(),
json["uf"]?.GetValue<string?>());
}
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<JsonObject>(
$"https://brasilapi.com.br/api/cep/v2/{cep}", ct);
if (json != null)
return BuildResult(cep,
json["street"]?.GetValue<string?>(),
json["neighborhood"]?.GetValue<string?>(),
json["city"]?.GetValue<string?>(),
json["state"]?.GetValue<string?>());
}
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<string>();
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));
}
}

View File

@ -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<GroqClient> _logger;
public GroqClient(HttpClient http, IConfiguration config, ILogger<GroqClient> logger)
{
_http = http;
_config = config;
_logger = logger;
}
public async Task<LlmCallResult> ChatAsync(
string systemPrompt,
string userMessage,
CancellationToken ct = default)
{
var model = _config["Groq:Model"] ?? "llama-3.3-70b-versatile";
var maxTokens = _config.GetValue<int>("Groq:MaxTokens", 500);
var temperature = _config.GetValue<double>("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<LlmCallResult> 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<string>();
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" };
}
}

View File

@ -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<AuthenticationSchemeOptions>
{
private readonly ApiKeyRepository _apiKeyRepo;
private readonly List<ApiKeyConfig> _configKeys;
public NaluAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ApiKeyRepository apiKeyRepo,
IConfiguration config)
: base(options, logger, encoder)
{
_apiKeyRepo = apiKeyRepo;
_configKeys = config.GetSection("ApiKeys").Get<List<ApiKeyConfig>>() ?? [];
}
protected override async Task<AuthenticateResult> 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);
}
}

View File

@ -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<OpenRouterClient> _logger;
public OpenRouterClient(HttpClient http, IConfiguration config, ILogger<OpenRouterClient> logger)
{
_http = http;
_config = config;
_logger = logger;
}
public async Task<LlmCallResult> ChatAsync(
string systemPrompt,
string userMessage,
CancellationToken ct = default)
{
var model = _config["OpenRouter:Model"] ?? "mistralai/mistral-7b-instruct";
var maxTokens = _config.GetValue<int>("OpenRouter:MaxTokens", 500);
var temperature = _config.GetValue<double>("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<string>();
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 };
}
}
}

View File

@ -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;
/// <summary>
/// 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.
/// </summary>
public class McpServer
{
private readonly ValidatorLoader _loader;
private readonly IServiceScopeFactory _scopeFactory;
private readonly CreditService _credits;
private readonly ILogger<McpServer> _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<McpServer> logger)
{
_loader = loader;
_scopeFactory = scopeFactory;
_credits = credits;
_logger = logger;
}
public async Task<IResult> 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<object> 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<ReplyService>();
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<ExtractionPipeline>();
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;
}
}

View File

@ -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";
}

View File

@ -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; }
}

View File

@ -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";
}

View File

@ -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;
}

View File

@ -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<string> 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<string> StopWords { get; set; } = [];
public List<string> RejectPatterns { get; set; } = [];
public List<string> AcceptPatterns { get; set; } = [];
public Dictionary<string, string> Constraints { get; set; } = [];
// LLM
public string Prompt { get; set; } = "";
public List<FewShotExample> FewShotExamples { get; set; } = [];
// Processing
public List<string> PostProcessors { get; set; } = [];
public List<string> Enrichers { get; set; } = [];
// Suggestions — flat (default) and localized (keyed by locale e.g. "pt-BR")
public Dictionary<string, string> Suggestions { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, Dictionary<string, string>> LocalizedSuggestions { get; set; } = new(StringComparer.OrdinalIgnoreCase);
// Localized stop words (keyed by locale)
public Dictionary<string, HashSet<string>> 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; }
}

View File

@ -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<string> Languages { get; init; }
}

View File

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Nalu.Web</RootNamespace>
<AssemblyName>Nalu.Web</AssemblyName>
</PropertyGroup>
<ItemGroup>
<None Update="Validators\*.md">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="AspNet.Security.OAuth.GitHub" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
<PackageReference Include="MongoDB.Driver" Version="3.4.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.14.11" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,2 @@
@page "/auth/callback"
@model Nalu.Web.Pages.Auth.CallbackModel

View File

@ -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<CallbackModel> logger) : PageModel
{
public async Task<IActionResult> 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<Claim>
{
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("//");
}

View File

@ -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.";
}
<!-- Header -->
<section class="bg-gradient-to-b from-slate-50 to-white pt-16 pb-10">
<div class="max-w-3xl mx-auto px-4 sm:px-6">
<div class="flex items-center gap-3 mb-4">
<a href="/casos" class="text-gray-400 hover:text-nalu-600 text-sm">← Casos de uso</a>
</div>
<div class="text-xs font-semibold text-nalu-600 uppercase tracking-wide mb-3">Caso de uso · validate_reply</div>
<h1 class="text-3xl sm:text-4xl font-extrabold text-gray-900 leading-tight mb-4">
"O bug das 48 parcelas": quando seu chatbot confunde quantidade com valor
</h1>
<p class="text-xl text-gray-500">
Acontece todo dia em chatbots de cobrança, vendas e atendimento. E custa vendas.
</p>
</div>
</section>
<!-- Intro -->
<section class="py-10 bg-white">
<div class="max-w-3xl mx-auto px-4 sm:px-6 prose prose-gray max-w-none">
<p class="text-gray-600 leading-relaxed text-lg">
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 <em>quantidade de parcelas</em> que o cliente propôs com um <em>valor em reais</em>.
</p>
<p class="text-gray-600 leading-relaxed">
Resultado: proposta errada, cliente frustrado, venda perdida.
E o pior: o bug é silencioso — o bot não sabe que errou.
</p>
</div>
</section>
<!-- O bug em ação -->
<section class="py-10 bg-red-50">
<div class="max-w-3xl mx-auto px-4 sm:px-6">
<h2 class="text-2xl font-bold text-red-700 mb-6">O bug em ação</h2>
<div class="bg-white border border-red-100 rounded-2xl p-6">
<div class="text-xs font-semibold text-red-500 uppercase tracking-wide mb-4">BOT TRADICIONAL — DIÁLOGO REAL</div>
<div class="space-y-3 font-mono text-sm">
<div class="flex gap-3">
<span class="text-gray-400 shrink-0">Agente:</span>
<span>"Posso parcelar em 20x de R$100. Topa?"</span>
</div>
<div class="flex gap-3">
<span class="text-gray-400 shrink-0">Usuário:</span>
<span>"Bora em 48?"</span>
</div>
<div class="mt-4 bg-red-50 rounded-lg p-3">
<div class="text-red-600 font-bold">❌ Bot extraiu: "48"</div>
<div class="text-red-500 text-xs mt-1">Interpretou como: R$ 48,00</div>
<div class="text-red-500 text-xs">Correto seria: 48 parcelas (contraproposta)</div>
</div>
</div>
</div>
</div>
</section>
<!-- Por que o LLM erra -->
<section class="py-10 bg-white">
<div class="max-w-3xl mx-auto px-4 sm:px-6">
<h2 class="text-2xl font-bold mb-4">Por que o LLM erra sozinho</h2>
<p class="text-gray-600 leading-relaxed mb-4">
O LLM genérico recebe apenas a mensagem do usuário: <strong>"Bora em 48?"</strong>.
Sem contexto de que o agente acabou de oferecer PARCELAS, "48" parece um valor monetário
ou simplesmente um número sem significado definido.
</p>
<p class="text-gray-600 leading-relaxed">
Mesmo com um sistema prompt bem elaborado, o LLM não foi instruído a analisar
o <strong>par agente+usuário</strong> como uma unidade semântica — ele vê só
a resposta isolada.
</p>
</div>
</section>
<!-- Como NALU resolve -->
<section class="py-10 bg-green-50">
<div class="max-w-3xl mx-auto px-4 sm:px-6">
<h2 class="text-2xl font-bold text-green-700 mb-6">Como o NALU AI resolve com validate_reply</h2>
<div class="bg-white border border-green-100 rounded-2xl p-6 mb-6">
<div class="text-xs font-semibold text-green-600 uppercase tracking-wide mb-4">API RESPONSE — validate_reply</div>
<pre class="font-mono text-sm text-gray-700 leading-relaxed overflow-x-auto">{
"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?"
}</pre>
</div>
<p class="text-gray-600 text-sm">
O <strong>validate_reply</strong> 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.
</p>
</div>
</section>
<!-- Custo -->
<section class="py-10 bg-white">
<div class="max-w-3xl mx-auto px-4 sm:px-6">
<h2 class="text-2xl font-bold mb-6">Custo de resolver</h2>
<div class="grid sm:grid-cols-2 gap-6">
<div class="bg-nalu-50 border border-nalu-100 rounded-2xl p-5 text-center">
<div class="text-sm text-gray-500 mb-1">Custo por análise</div>
<div class="text-4xl font-extrabold text-nalu-600">R$ 0,0097</div>
<div class="text-sm text-gray-500 mt-1">5 créditos · plano Starter</div>
</div>
<div class="bg-red-50 border border-red-100 rounded-2xl p-5 text-center">
<div class="text-sm text-gray-500 mb-1">Custo de NÃO resolver</div>
<div class="text-2xl font-extrabold text-red-600">Venda perdida</div>
<div class="text-sm text-gray-500 mt-1">Cliente frustrado · dados poluídos</div>
</div>
</div>
</div>
</section>
<!-- Código -->
<section class="py-10 bg-slate-900 text-white">
<div class="max-w-3xl mx-auto px-4 sm:px-6">
<h2 class="text-2xl font-bold mb-6">Código de integração</h2>
<div class="space-y-4">
<div class="text-sm font-semibold text-slate-400 uppercase tracking-wide">cURL</div>
<pre class="bg-slate-800 rounded-xl p-5 text-sm font-mono text-slate-300 leading-relaxed overflow-x-auto">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"
}'</pre>
<div class="text-sm font-semibold text-slate-400 uppercase tracking-wide mt-6">JavaScript (n8n / Make)</div>
<pre class="bg-slate-800 rounded-xl p-5 text-sm font-mono text-slate-300 leading-relaxed overflow-x-auto">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'</pre>
</div>
</div>
</section>
<!-- CTA -->
<section class="py-16 bg-nalu-600 text-white text-center">
<div class="max-w-xl mx-auto px-4 sm:px-6">
<h2 class="text-2xl font-bold mb-3">Teste com seus próprios diálogos</h2>
<p class="text-nalu-100 mb-6">O playground é grátis. 50 créditos por dia, sem cadastro.</p>
<a href="/playground?validator=reply" class="bg-white text-nalu-600 font-bold px-8 py-3 rounded-xl hover:bg-nalu-50 transition-colors inline-block">
Testar no playground →
</a>
<p class="text-nalu-200 text-sm mt-4">Ou <a href="/precos" class="underline">criar uma conta</a> e ganhar 3.000 créditos grátis.</p>
</div>
</section>

View File

@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Nalu.Web.Pages.Casos;
public class Parcelas48xModel : PageModel
{
public void OnGet() { }
}

View File

@ -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.";
}
<!-- ── 1. HERO ─────────────────────────────────────────────────────────────── -->
<section class="bg-gradient-to-b from-slate-50 to-white pt-20 pb-16">
<div class="max-w-4xl mx-auto px-4 sm:px-6 text-center">
<div class="inline-flex items-center gap-2 bg-nalu-50 text-nalu-700 text-xs font-semibold px-3 py-1 rounded-full mb-6">
13 validadores · MCP + REST · 3.000 créditos grátis
</div>
<h1 class="text-4xl sm:text-5xl font-extrabold text-gray-900 leading-tight mb-6">
Seu chatbot está gravando<br>
<span class="text-nalu-600">"Bom Dia"</span> como nome do cliente.
</h1>
<p class="text-xl text-gray-500 mb-8 max-w-2xl mx-auto">
NALU AI extrai o que o usuário <em>realmente</em> disse — nome, CPF, CEP, parcelas —
sem confundir saudação com dado. Integra em 30 segundos.
</p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-3 mb-8">
<a href="/precos" class="bg-nalu-600 text-white font-semibold px-6 py-3 rounded-xl hover:bg-nalu-700 transition-colors text-base">
Começar grátis →
</a>
<a href="/playground" class="border border-gray-300 text-gray-700 font-medium px-6 py-3 rounded-xl hover:border-nalu-600 hover:text-nalu-600 transition-colors text-base">
Testar no playground
</a>
</div>
<ul class="flex flex-col sm:flex-row items-center justify-center gap-4 text-sm text-gray-600 mb-8">
<li class="flex items-center gap-2">✓ 3.000 créditos grátis por mês</li>
<li class="flex items-center gap-2">✓ Setup em 30 segundos</li>
<li class="flex items-center gap-2">✓ Funciona com n8n, Make, Claude Code, Cursor</li>
</ul>
<div class="text-center">
<span class="text-3xl font-extrabold text-nalu-600">R$ 0,0019</span>
<span class="text-gray-500 ml-2 text-base">por validação no plano Starter.</span>
<p class="text-sm text-gray-400 mt-1">Menos de 1 centavo para nunca mais gravar "Bom Dia" como nome.</p>
</div>
</div>
</section>
<!-- ── 2. BEFORE / AFTER ─────────────────────────────────────────────────────── -->
<section class="py-16 bg-white">
<div class="max-w-5xl mx-auto px-4 sm:px-6">
<h2 class="text-3xl font-bold text-center mb-12">O problema (que todo mundo já teve)</h2>
<!-- Example 1: Bom Dia -->
<div class="grid md:grid-cols-2 gap-6 mb-10">
<div class="bg-red-50 border border-red-100 rounded-2xl p-6">
<div class="text-xs font-semibold text-red-500 uppercase tracking-wide mb-3">BOT TRADICIONAL</div>
<div class="font-mono text-sm space-y-2">
<div><span class="text-gray-400">Agente:</span> Olá! Qual o seu nome?</div>
<div><span class="text-gray-400">Usuário:</span> Bom dia! Me chamo João Silva</div>
<div class="mt-3 text-red-600 font-semibold">❌ Gravou: "Bom Dia Me Chamo João Silva"</div>
</div>
</div>
<div class="bg-green-50 border border-green-100 rounded-2xl p-6">
<div class="text-xs font-semibold text-green-600 uppercase tracking-wide mb-3">COM NALU AI (validate_name)</div>
<div class="font-mono text-sm space-y-2">
<div><span class="text-gray-400">extracted_value:</span> <span class="text-green-700 font-bold">"João Silva"</span></div>
<div><span class="text-gray-400">certain:</span> true</div>
<div><span class="text-gray-400">confidence:</span> "high"</div>
</div>
<div class="mt-3 text-xs text-gray-500">Custo: R$ 0,0039 (2 créditos)</div>
</div>
</div>
<!-- Example 2: Parcelas 48x -->
<div class="grid md:grid-cols-2 gap-6 mb-10">
<div class="bg-red-50 border border-red-100 rounded-2xl p-6">
<div class="text-xs font-semibold text-red-500 uppercase tracking-wide mb-3">BOT TRADICIONAL</div>
<div class="font-mono text-sm space-y-2">
<div><span class="text-gray-400">Agente:</span> Posso parcelar em 20x de R$100. Topa?</div>
<div><span class="text-gray-400">Usuário:</span> Bora em 48?</div>
<div class="mt-3 text-red-600 font-semibold">❌ Bot entendeu: R$ 48,00</div>
</div>
</div>
<div class="bg-green-50 border border-green-100 rounded-2xl p-6">
<div class="text-xs font-semibold text-green-600 uppercase tracking-wide mb-3">COM NALU AI (validate_reply) <span class="bg-nalu-100 text-nalu-700 text-xs px-2 py-0.5 rounded-full ml-1">5 créditos</span></div>
<div class="font-mono text-sm space-y-2">
<div><span class="text-gray-400">reply_type:</span> <span class="text-green-700 font-bold">counter_proposal</span></div>
<div><span class="text-gray-400">extracted_value:</span> "48 parcelas"</div>
<div><span class="text-gray-400">suggestion:</span> "Cliente propõe 48 parcelas..."</div>
</div>
<div class="mt-3 text-xs text-gray-500">Custo: R$ 0,0097 (5 créditos)</div>
</div>
</div>
<!-- Example 3: CEP -->
<div class="grid md:grid-cols-2 gap-6">
<div class="bg-red-50 border border-red-100 rounded-2xl p-6">
<div class="text-xs font-semibold text-red-500 uppercase tracking-wide mb-3">BOT TRADICIONAL</div>
<div class="font-mono text-sm space-y-2">
<div><span class="text-gray-400">Agente:</span> Qual seu CEP?</div>
<div><span class="text-gray-400">Usuário:</span> É o 01310-100</div>
<div class="mt-3 text-red-600 font-semibold">❌ Regex falhou: "É o 01310-100" não é só dígitos</div>
</div>
</div>
<div class="bg-green-50 border border-green-100 rounded-2xl p-6">
<div class="text-xs font-semibold text-green-600 uppercase tracking-wide mb-3">COM NALU AI (validate_cep)</div>
<div class="font-mono text-sm space-y-2">
<div><span class="text-gray-400">extracted_value:</span> <span class="text-green-700 font-bold">"01310-100"</span></div>
<div><span class="text-gray-400">cidade:</span> "São Paulo"</div>
<div><span class="text-gray-400">bairro:</span> "Bela Vista"</div>
</div>
<div class="mt-3 text-xs text-gray-500">Custo: R$ 0,0019 (1 crédito)</div>
</div>
</div>
</div>
</section>
<!-- ── 3. HOW IT WORKS ──────────────────────────────────────────────────────── -->
<section class="py-16 bg-slate-50">
<div class="max-w-4xl mx-auto px-4 sm:px-6 text-center">
<h2 class="text-3xl font-bold mb-12">Como funciona</h2>
<div class="grid md:grid-cols-3 gap-8">
<div class="bg-white rounded-2xl p-6 shadow-sm">
<div class="text-3xl mb-4">📨</div>
<h3 class="font-bold text-lg mb-2">1. Envie o diálogo</h3>
<p class="text-gray-500 text-sm">Agente + resposta do usuário. Dois campos. Nada mais.</p>
</div>
<div class="bg-white rounded-2xl p-6 shadow-sm">
<div class="text-3xl mb-4">🧠</div>
<h3 class="font-bold text-lg mb-2">2. NALU extrai</h3>
<p class="text-gray-500 text-sm">Regras determinísticas primeiro. LLM só quando necessário. Resultado normalizado.</p>
</div>
<div class="bg-white rounded-2xl p-6 shadow-sm">
<div class="text-3xl mb-4">✅</div>
<h3 class="font-bold text-lg mb-2">3. Use o dado limpo</h3>
<p class="text-gray-500 text-sm"><code class="text-nalu-600">obtained: true</code> + valor validado. Sem regex, sem alucinação.</p>
</div>
</div>
</div>
</section>
<!-- ── 4. COMIC 1 PLACEHOLDER ─────────────────────────────────────────────── -->
<section class="py-8 bg-white">
<div class="max-w-3xl mx-auto px-4 sm:px-6">
<img src="/images/comic-1-pt.webp" alt="Quadrinho: bot gravando Bom Dia como nome" class="rounded-2xl w-full shadow-md" onerror="this.style.display='none'" />
</div>
</section>
<!-- ── 5. VALIDATOR CATALOG ───────────────────────────────────────────────── -->
<section class="py-16 bg-slate-50">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<h2 class="text-3xl font-bold text-center mb-4">13 validadores prontos</h2>
<p class="text-center text-gray-500 mb-10">Determinísticos + LLM leve + análise de contexto (70B)</p>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
@foreach (var v in Model.Validators)
{
<div class="bg-white border border-gray-100 rounded-xl p-5 hover:border-nalu-300 transition-colors @(v.IsNew ? "ring-2 ring-nalu-400" : "")">
<div class="flex items-start justify-between mb-2">
<div class="font-semibold text-gray-800 text-sm">@v.Icon @v.Name</div>
<div class="flex items-center gap-1">
@if (v.IsNew)
{
<span class="bg-nalu-500 text-white text-xs px-2 py-0.5 rounded-full font-semibold">NEW</span>
}
<span class="bg-gray-100 text-gray-500 text-xs px-2 py-0.5 rounded-full">@v.Credits cr</span>
</div>
</div>
<p class="text-xs text-gray-500 leading-relaxed">@v.Description</p>
</div>
}
</div>
<div class="text-center mt-8">
<a href="/validadores" class="text-nalu-600 font-medium text-sm hover:underline">Ver todos os validadores →</a>
</div>
</div>
</section>
<!-- ── 6. STATE MACHINE ───────────────────────────────────────────────────── -->
<section class="py-16 bg-white">
<div class="max-w-4xl mx-auto px-4 sm:px-6">
<h2 class="text-3xl font-bold text-center mb-4">Máquina de estados inteligente</h2>
<p class="text-center text-gray-500 mb-10">Validadores podem ser encadeados em fluxos completos.</p>
<div class="bg-slate-50 rounded-2xl p-6 font-mono text-sm overflow-x-auto">
<pre class="text-gray-600 leading-relaxed">
[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 = <span class="text-nalu-600 font-bold">R$ 0,0133</span> no Starter.
Mais barato que perder a venda.</pre>
</div>
</div>
</section>
<!-- ── 7. COMIC 2 PLACEHOLDER ─────────────────────────────────────────────── -->
<section class="py-8 bg-white">
<div class="max-w-3xl mx-auto px-4 sm:px-6">
<p class="text-center text-sm text-gray-500 mb-4">
Com o <strong>validate_reply</strong> 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: <strong>R$ 0,0097</strong>. Menos que o cafezinho.
</p>
<img src="/images/comic-2-pt.webp" alt="Quadrinho: 48 parcelas vs R$48" class="rounded-2xl w-full shadow-md" onerror="this.style.display='none'" />
</div>
</section>
<!-- ── 8. CODE SNIPPETS ───────────────────────────────────────────────────── -->
<section class="py-16 bg-slate-900 text-white">
<div class="max-w-4xl mx-auto px-4 sm:px-6">
<h2 class="text-3xl font-bold text-center mb-10">Integração em 30 segundos</h2>
<div x-data="{ tab: 'curl' }" class="space-y-4">
<div class="flex gap-2 text-sm font-medium overflow-x-auto pb-2">
<button onclick="showTab('curl')" id="tab-curl" class="tab-btn tab-active px-4 py-2 rounded-lg bg-slate-700 text-white">cURL</button>
<button onclick="showTab('reply')" id="tab-reply" class="tab-btn px-4 py-2 rounded-lg text-slate-400 hover:text-white">validate_reply</button>
<button onclick="showTab('js')" id="tab-js" class="tab-btn px-4 py-2 rounded-lg text-slate-400 hover:text-white">JavaScript</button>
<button onclick="showTab('csharp')" id="tab-csharp" class="tab-btn px-4 py-2 rounded-lg text-slate-400 hover:text-white">C#</button>
</div>
<div id="content-curl" class="tab-content bg-slate-800 rounded-xl p-6 text-sm font-mono text-slate-300 leading-relaxed overflow-x-auto">
<pre>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>
</div>
<div id="content-reply" class="tab-content hidden bg-slate-800 rounded-xl p-6 text-sm font-mono text-slate-300 leading-relaxed overflow-x-auto">
<pre># validate_reply — Análise de contexto conversacional (5 créditos)
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?"
}'
# 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..."
# }</pre>
</div>
<div id="content-js" class="tab-content hidden bg-slate-800 rounded-xl p-6 text-sm font-mono text-slate-300 leading-relaxed overflow-x-auto">
<pre>const res = await fetch('https://api.naluai.com/v1/extract/name', {
method: 'POST',
headers: {
'Authorization': 'Bearer SEU_TOKEN',
'Content-Type': 'application/json'
},
body: JSON.stringify({
agent_input: 'Qual o seu nome?',
user_input: 'Bom dia! Me chamo João Silva'
})
});
const { obtained, extracted_value } = await res.json();
// obtained: true, extracted_value: "João Silva"</pre>
</div>
<div id="content-csharp" class="tab-content hidden bg-slate-800 rounded-xl p-6 text-sm font-mono text-slate-300 leading-relaxed overflow-x-auto">
<pre>using var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization", "Bearer SEU_TOKEN");
var body = new {
agent_input = "Qual o seu nome?",
user_input = "Bom dia! Me chamo João Silva"
};
var resp = await client.PostAsJsonAsync(
"https://api.naluai.com/v1/extract/name", body);
var result = await resp.Content.ReadFromJsonAsync&lt;ExtractionResponse&gt;();
// result.ExtractedValue == "João Silva"</pre>
</div>
</div>
</div>
</section>
<!-- ── 9. PRICING SUMMARY ─────────────────────────────────────────────────── -->
<section class="py-16 bg-white">
<div class="max-w-5xl mx-auto px-4 sm:px-6">
<h2 class="text-3xl font-bold text-center mb-4">Preços</h2>
<p class="text-center text-gray-500 mb-10">
<span class="text-2xl font-bold text-nalu-600">R$ 0,0019</span>
<span class="text-gray-500"> por validação no plano Starter.</span>
</p>
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
<div class="border border-gray-200 rounded-2xl p-6 text-center">
<div class="font-bold text-gray-900 mb-2">Free</div>
<div class="text-3xl font-extrabold text-gray-900 mb-1">R$ 0</div>
<div class="text-gray-500 text-sm mb-4">3.000 créditos/mês</div>
<a href="/precos" class="block border border-gray-300 rounded-lg py-2 text-sm font-medium hover:border-nalu-500 transition-colors">Começar grátis</a>
</div>
<div class="border-2 border-nalu-500 rounded-2xl p-6 text-center relative">
<div class="absolute -top-3 left-1/2 -translate-x-1/2 bg-nalu-500 text-white text-xs px-3 py-1 rounded-full font-semibold">Popular</div>
<div class="font-bold text-gray-900 mb-1">Starter</div>
<div class="text-2xl font-extrabold text-nalu-600 mb-0">R$ 0,0019</div>
<div class="text-xs text-gray-400 mb-1">por validação</div>
<div class="text-gray-400 text-xs mb-4">R$ 29/mês · 15.000 créditos</div>
<a href="/precos" class="block bg-nalu-600 text-white rounded-lg py-2 text-sm font-semibold hover:bg-nalu-700 transition-colors">Assinar →</a>
</div>
<div class="border border-gray-200 rounded-2xl p-6 text-center">
<div class="font-bold text-gray-900 mb-1">Indie</div>
<div class="text-2xl font-extrabold text-nalu-600 mb-0">R$ 0,0014</div>
<div class="text-xs text-gray-400 mb-1">por validação</div>
<div class="text-gray-400 text-xs mb-4">R$ 69/mês · 50.000 créditos</div>
<a href="/precos" class="block border border-gray-300 rounded-lg py-2 text-sm font-medium hover:border-nalu-500 transition-colors">Assinar →</a>
</div>
<div class="border border-gray-200 rounded-2xl p-6 text-center">
<div class="font-bold text-gray-900 mb-1">Pro</div>
<div class="text-2xl font-extrabold text-nalu-600 mb-0">R$ 0,0008</div>
<div class="text-xs text-gray-400 mb-1">por validação</div>
<div class="text-gray-400 text-xs mb-4">R$ 199/mês · 250.000 créditos</div>
<a href="/precos" class="block border border-gray-300 rounded-lg py-2 text-sm font-medium hover:border-nalu-500 transition-colors">Assinar →</a>
</div>
</div>
<!-- Callout -->
<div class="bg-blue-50 border border-blue-100 rounded-2xl p-6">
<div class="text-blue-700 font-bold mb-2">💡 Fazendo a conta</div>
<p class="text-blue-800 text-sm leading-relaxed">
Um chatbot de cobrança faz ~500 validações por mês.
Com o plano Starter, isso custa <strong>R$ 0,95</strong>.
Menos que um café. Para nunca mais perder um cliente
por um bot que confundiu 48 parcelas com R$ 48.
</p>
<p class="text-blue-600 font-semibold text-sm mt-2">
Qual o custo de perder uma venda por um erro do bot?
</p>
</div>
</div>
</section>
<!-- ── 10. FAQ ────────────────────────────────────────────────────────────── -->
<section class="py-16 bg-slate-50">
<div class="max-w-3xl mx-auto px-4 sm:px-6">
<h2 class="text-3xl font-bold text-center mb-10">FAQ</h2>
<div class="space-y-4">
@foreach (var faq in Model.Faqs)
{
<div class="bg-white rounded-xl border border-gray-100 p-5">
<div class="font-semibold text-gray-800 mb-2">@faq.Q</div>
<div class="text-gray-500 text-sm leading-relaxed">@faq.A</div>
</div>
}
</div>
</div>
</section>
<!-- ── 12. FINAL CTA ──────────────────────────────────────────────────────── -->
<section class="py-20 bg-nalu-600 text-white text-center">
<div class="max-w-2xl mx-auto px-4 sm:px-6">
<h2 class="text-3xl font-bold mb-4">Pare de perder dados (e clientes) por regex ruim.</h2>
<p class="text-nalu-100 mb-8 text-lg">3.000 créditos grátis por mês. Sem cartão. Setup em 30 segundos.</p>
<a href="/precos" class="bg-white text-nalu-600 font-bold px-8 py-4 rounded-xl hover:bg-nalu-50 transition-colors text-lg inline-block">
Começar grátis →
</a>
<p class="text-nalu-200 text-sm mt-4">A partir de R$ 0,0019 por validação. Menos que uma gota de café.</p>
</div>
</section>
@section Scripts {
<script>
function showTab(name) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.tab-btn').forEach(el => {
el.classList.remove('bg-slate-700', 'text-white');
el.classList.add('text-slate-400');
});
document.getElementById('content-' + name).classList.remove('hidden');
var btn = document.getElementById('tab-' + name);
btn.classList.add('bg-slate-700', 'text-white');
btn.classList.remove('text-slate-400');
}
</script>
}

View File

@ -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<ValidatorCard> 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<FaqItem> 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() { }
}

View File

@ -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";
}
<section class="min-h-[80vh] flex items-center justify-center bg-gradient-to-b from-slate-50 to-white py-16 px-4">
<div class="w-full max-w-sm">
<!-- Logo -->
<div class="text-center mb-8">
<a href="/" class="inline-flex items-center gap-1 font-bold text-2xl">
<span class="text-gray-300">NA</span><span class="text-nalu-600">LU</span>
<span class="text-gray-500 font-normal text-base ml-1">AI</span>
</a>
<p class="text-gray-500 mt-2 text-sm">Entre para acessar seu painel e API key</p>
</div>
<!-- Card -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-8">
<h1 class="text-xl font-bold text-gray-900 mb-6 text-center">Entrar na sua conta</h1>
<div class="space-y-3">
<!-- Google -->
<a href="/login?handler=Google&returnUrl=@Uri.EscapeDataString(Model.ReturnUrl ?? "/painel")"
class="flex items-center justify-center gap-3 w-full border border-gray-200 rounded-xl px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
<svg class="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Continuar com Google
</a>
<!-- Microsoft -->
<a href="/login?handler=Microsoft&returnUrl=@Uri.EscapeDataString(Model.ReturnUrl ?? "/painel")"
class="flex items-center justify-center gap-3 w-full border border-gray-200 rounded-xl px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
<svg class="w-5 h-5" viewBox="0 0 23 23">
<path fill="#f25022" d="M0 0h11v11H0z"/>
<path fill="#00a4ef" d="M12 0h11v11H12z"/>
<path fill="#7fba00" d="M0 12h11v11H0z"/>
<path fill="#ffb900" d="M12 12h11v11H12z"/>
</svg>
Continuar com Microsoft
</a>
<!-- GitHub -->
<a href="/login?handler=GitHub&returnUrl=@Uri.EscapeDataString(Model.ReturnUrl ?? "/painel")"
class="flex items-center justify-center gap-3 w-full border border-gray-200 rounded-xl px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 21.795 24 17.295 24 12c0-6.63-5.37-12-12-12"/>
</svg>
Continuar com GitHub
</a>
</div>
<p class="text-center text-xs text-gray-400 mt-6">
Ao entrar, você concorda com nossos
<a href="/termos" class="underline hover:text-gray-600">Termos</a> e
<a href="/privacidade" class="underline hover:text-gray-600">Privacidade</a>.
</p>
</div>
<p class="text-center text-xs text-gray-400 mt-4">
Novo por aqui? Sua conta é criada automaticamente no primeiro login.
<br>Você ganha <strong class="text-gray-600">3.000 créditos grátis</strong>.
</p>
</div>
</section>

View File

@ -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");
}

View File

@ -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";
}
<!-- Welcome modal (new users) -->
@if (Model.NewApiKey != null)
{
<div id="welcome-modal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full p-8">
<div class="text-4xl mb-4 text-center">🎉</div>
<h2 class="text-2xl font-bold text-gray-900 text-center mb-2">Bem-vindo ao NALU AI!</h2>
<p class="text-gray-500 text-sm text-center mb-6">
Sua conta foi criada com <strong>3.000 créditos grátis</strong>.
Guarde sua API key — ela só é exibida uma vez.
</p>
<div class="bg-slate-900 rounded-xl p-4 mb-4">
<div class="text-xs text-slate-400 mb-2 font-semibold uppercase tracking-wide">Sua API Key</div>
<div class="font-mono text-sm text-green-400 break-all" id="new-api-key">@Model.NewApiKey</div>
</div>
<button onclick="copyKey()"
class="w-full bg-nalu-600 text-white font-semibold py-2.5 rounded-xl hover:bg-nalu-700 transition-colors mb-3">
Copiar API Key
</button>
<button onclick="document.getElementById('welcome-modal').remove()"
class="w-full text-sm text-gray-500 hover:text-gray-700 py-2">
Entendi, já copiei
</button>
</div>
</div>
<script>
function copyKey() {
navigator.clipboard.writeText('@Model.NewApiKey');
const btn = event.target;
btn.textContent = '✓ Copiado!';
setTimeout(() => btn.textContent = 'Copiar API Key', 2000);
}
</script>
}
<div class="max-w-4xl mx-auto px-4 sm:px-6 py-12">
<!-- Header -->
<div class="flex items-center justify-between mb-10">
<div class="flex items-center gap-4">
@if (!string.IsNullOrEmpty(Model.UserPicture))
{
<img src="@Model.UserPicture" alt="Avatar" class="w-12 h-12 rounded-full border border-gray-200" />
}
else
{
<div class="w-12 h-12 rounded-full bg-nalu-100 flex items-center justify-center text-nalu-700 font-bold text-lg">
@(Model.UserName.Length > 0 ? Model.UserName[0].ToString().ToUpper() : "?")
</div>
}
<div>
<div class="font-bold text-gray-900">@Model.UserName</div>
<div class="text-sm text-gray-500">@Model.UserEmail</div>
</div>
</div>
<div class="flex items-center gap-3">
<span class="bg-nalu-100 text-nalu-700 text-xs font-bold px-3 py-1 rounded-full uppercase">@Model.Plan</span>
<a href="/auth/logout" class="text-sm text-gray-500 hover:text-gray-700">Sair</a>
</div>
</div>
<!-- Usage card -->
<div class="bg-white border border-gray-100 rounded-2xl p-6 mb-6 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h2 class="font-bold text-gray-900">Uso este mês</h2>
<span class="text-sm text-gray-500">Reseta em @(new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1).AddMonths(1).ToString("dd/MM"))</span>
</div>
<div class="flex items-end gap-3 mb-3">
<div class="text-3xl font-extrabold text-gray-900">@Model.CreditsUsed.ToString("N0")</div>
<div class="text-gray-500 mb-1">/ @Model.CreditsLimit.ToString("N0") créditos</div>
</div>
<div class="w-full bg-gray-100 rounded-full h-3">
<div class="@barColor h-3 rounded-full transition-all" style="width: @pct%"></div>
</div>
<div class="flex justify-between text-xs text-gray-400 mt-1">
<span>@(Model.CreditsLimit - Model.CreditsUsed) restantes</span>
<span>@pct%</span>
</div>
@if (Model.Plan == "free")
{
<div class="mt-4 pt-4 border-t border-gray-100 text-center">
<a href="/precos" class="text-sm font-semibold text-nalu-600 hover:text-nalu-700">
Fazer upgrade para mais créditos →
</a>
</div>
}
</div>
<!-- API Keys -->
<div class="bg-white border border-gray-100 rounded-2xl p-6 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h2 class="font-bold text-gray-900">API Keys</h2>
</div>
@if (Model.Keys.Count == 0)
{
<p class="text-sm text-gray-500 text-center py-8">Nenhuma key ativa.</p>
}
else
{
<div class="space-y-3">
@foreach (var k in Model.Keys)
{
<div class="flex items-center justify-between bg-slate-50 rounded-xl px-4 py-3">
<div class="flex-1 min-w-0 mr-4">
<div class="text-xs font-semibold text-gray-500 mb-1">@(k.Label ?? "API Key")</div>
<div class="font-mono text-sm text-gray-800 truncate">
@(k.Key[..Math.Min(20, k.Key.Length)])…
</div>
@if (k.LastUsedAt.HasValue)
{
<div class="text-xs text-gray-400 mt-0.5">Último uso: @k.LastUsedAt.Value.ToString("dd/MM/yyyy HH:mm") UTC</div>
}
</div>
<div class="flex items-center gap-2">
<button onclick="copyApiKey('@k.Key', this)"
class="text-xs text-nalu-600 hover:text-nalu-700 font-medium px-3 py-1.5 border border-nalu-200 rounded-lg">
Copiar
</button>
<form method="post" asp-page-handler="Revoke" onsubmit="return confirm('Revogar esta key?')">
<input type="hidden" name="key" value="@k.Key" />
<button type="submit"
class="text-xs text-red-500 hover:text-red-700 font-medium px-3 py-1.5 border border-red-200 rounded-lg">
Revogar
</button>
</form>
</div>
</div>
}
</div>
}
<!-- Usage hint -->
<div class="mt-4 bg-slate-900 rounded-xl p-4">
<div class="text-xs text-slate-400 mb-2 font-semibold uppercase tracking-wide">Como usar</div>
<pre class="font-mono text-sm text-slate-300 leading-relaxed overflow-x-auto">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"}'</pre>
</div>
</div>
</div>
@section Scripts {
<script>
function copyApiKey(key, btn) {
navigator.clipboard.writeText(key);
const orig = btn.textContent;
btn.textContent = '✓ Copiado';
setTimeout(() => btn.textContent = orig, 2000);
}
</script>
}

View File

@ -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<ApiKey> 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<int>("credits_per_month");
if (CreditsLimit == 0) CreditsLimit = 3000;
// Welcome modal — shown once
NewApiKey = TempData["NewApiKey"] as string;
}
public async Task<IActionResult> 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();
}
}

View File

@ -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é.";
}
<!-- Hero de pricing -->
<section class="bg-gradient-to-b from-slate-50 to-white pt-20 pb-10">
<div class="max-w-3xl mx-auto px-4 sm:px-6 text-center">
<h1 class="text-3xl font-bold text-gray-600 mb-2">Quanto custa consertar seu chatbot?</h1>
<div class="text-7xl font-extrabold text-nalu-600 my-4">R$ 0,0019</div>
<p class="text-xl text-gray-500 mb-2">por validação no plano Starter.</p>
<div class="space-y-1 text-gray-400 text-base">
<p>Menos que uma gota de café por chamada.</p>
<p>Menos que o custo de um SMS.</p>
<p>Menos que o prejuízo de <em>UM</em> cliente que desistiu</p>
<p>porque o bot não entendeu o que ele disse.</p>
</div>
</div>
</section>
<!-- Cards de plano -->
<section class="py-12 bg-white">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Free -->
<div class="border border-gray-200 rounded-2xl p-6 flex flex-col">
<div class="font-bold text-gray-900 text-lg mb-1">Free</div>
<div class="text-gray-400 text-sm mb-4">Para testar e projetos pessoais</div>
<div class="text-4xl font-extrabold text-gray-900 mb-1">R$ 0</div>
<div class="text-gray-400 text-sm mb-6">para sempre</div>
<ul class="text-sm text-gray-600 space-y-2 mb-6 flex-1">
<li>✓ 3.000 créditos/mês</li>
<li>✓ 13 validadores</li>
<li>✓ Playground</li>
<li>✓ Docs completa</li>
<li class="text-gray-400"> Email: sem suporte</li>
</ul>
<a href="/login" class="block text-center border border-gray-300 rounded-xl py-2.5 text-sm font-semibold hover:border-nalu-500 hover:text-nalu-600 transition-colors">
Começar grátis
</a>
</div>
<!-- Starter -->
<div class="border-2 border-nalu-500 rounded-2xl p-6 flex flex-col relative">
<div class="absolute -top-3.5 left-1/2 -translate-x-1/2 bg-nalu-500 text-white text-xs px-4 py-1 rounded-full font-bold">Mais popular</div>
<div class="font-bold text-gray-900 text-lg mb-1">Starter</div>
<div class="text-gray-500 text-sm mb-3">Para startups e MVPs</div>
<div class="text-3xl font-extrabold text-nalu-600 mb-0">R$ 0,0019</div>
<div class="text-xs text-gray-400 mb-1">por validação</div>
<div class="text-gray-400 text-sm mb-6">R$ 29/mês · 15.000 créditos</div>
<ul class="text-sm text-gray-600 space-y-2 mb-6 flex-1">
<li>✓ 15.000 créditos/mês</li>
<li>✓ Tudo do Free</li>
<li>✓ Dashboard</li>
<li>✓ Email 72h</li>
</ul>
<a href="/checkout?plan=starter" class="block text-center bg-nalu-600 text-white rounded-xl py-2.5 text-sm font-bold hover:bg-nalu-700 transition-colors">
Assinar →
</a>
</div>
<!-- Indie -->
<div class="border border-gray-200 rounded-2xl p-6 flex flex-col">
<div class="font-bold text-gray-900 text-lg mb-1">Indie</div>
<div class="text-gray-500 text-sm mb-3">Para produtos em crescimento</div>
<div class="text-3xl font-extrabold text-nalu-600 mb-0">R$ 0,0014</div>
<div class="text-xs text-gray-400 mb-1">por validação</div>
<div class="text-gray-400 text-sm mb-6">R$ 69/mês · 50.000 créditos</div>
<ul class="text-sm text-gray-600 space-y-2 mb-6 flex-1">
<li>✓ 50.000 créditos/mês</li>
<li>✓ Tudo do Starter</li>
<li>✓ Email 24h</li>
<li>✓ Priority queue</li>
</ul>
<a href="/checkout?plan=indie" class="block text-center border border-gray-300 rounded-xl py-2.5 text-sm font-semibold hover:border-nalu-500 hover:text-nalu-600 transition-colors">
Assinar →
</a>
</div>
<!-- Pro -->
<div class="border border-gray-200 rounded-2xl p-6 flex flex-col">
<div class="font-bold text-gray-900 text-lg mb-1">Pro</div>
<div class="text-gray-500 text-sm mb-3">Para produtos em escala</div>
<div class="text-3xl font-extrabold text-nalu-600 mb-0">R$ 0,0008</div>
<div class="text-xs text-gray-400 mb-1">por validação</div>
<div class="text-gray-400 text-sm mb-6">R$ 199/mês · 250.000 créditos</div>
<ul class="text-sm text-gray-600 space-y-2 mb-6 flex-1">
<li>✓ 250.000 créditos/mês</li>
<li>✓ Tudo do Indie</li>
<li>✓ SLA 99%</li>
<li>✓ Suporte 8h</li>
</ul>
<a href="/checkout?plan=pro" class="block text-center border border-gray-300 rounded-xl py-2.5 text-sm font-semibold hover:border-nalu-500 hover:text-nalu-600 transition-colors">
Assinar →
</a>
</div>
</div>
</div>
</section>
<!-- Tabela de créditos por validador -->
<section class="py-12 bg-slate-50">
<div class="max-w-3xl mx-auto px-4 sm:px-6">
<h2 class="text-2xl font-bold text-center mb-6">Quanto custa cada validador?</h2>
<div class="bg-white rounded-2xl border border-gray-100 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="px-5 py-3 text-left font-semibold text-gray-700">Validador</th>
<th class="px-5 py-3 text-center font-semibold text-gray-700">Créditos</th>
<th class="px-5 py-3 text-center font-semibold text-gray-700">Starter</th>
<th class="px-5 py-3 text-center font-semibold text-gray-700">Pro</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
<tr class="hover:bg-gray-50">
<td class="px-5 py-3 text-gray-700">CPF, CEP, CNPJ, email, telefone, placa, CEP internacional</td>
<td class="px-5 py-3 text-center font-bold text-gray-900">1</td>
<td class="px-5 py-3 text-center text-nalu-600 font-semibold">R$ 0,0019</td>
<td class="px-5 py-3 text-center text-nalu-600 font-semibold">R$ 0,0008</td>
</tr>
<tr class="hover:bg-gray-50">
<td class="px-5 py-3 text-gray-700">Nome, sim/não, data nascimento, handoff, cancelamento, empresa</td>
<td class="px-5 py-3 text-center font-bold text-gray-900">2</td>
<td class="px-5 py-3 text-center text-nalu-600 font-semibold">R$ 0,0039</td>
<td class="px-5 py-3 text-center text-nalu-600 font-semibold">R$ 0,0016</td>
</tr>
<tr class="bg-nalu-50 hover:bg-nalu-100">
<td class="px-5 py-3 text-gray-700 font-semibold">
🧠 validate_reply (análise de contexto)
<span class="ml-2 bg-nalu-500 text-white text-xs px-2 py-0.5 rounded-full">Premium</span>
</td>
<td class="px-5 py-3 text-center font-bold text-gray-900">5</td>
<td class="px-5 py-3 text-center text-nalu-600 font-bold">R$ 0,0097</td>
<td class="px-5 py-3 text-center text-nalu-600 font-bold">R$ 0,0040</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Quanto rende cada plano -->
<section class="py-12 bg-white">
<div class="max-w-3xl mx-auto px-4 sm:px-6">
<h2 class="text-2xl font-bold text-center mb-8">Quanto rende cada plano?</h2>
<div class="bg-blue-50 border border-blue-100 rounded-2xl p-6 mb-6">
<div class="font-bold text-blue-800 mb-3">💡 Exemplo real: chatbot de cobrança via WhatsApp</div>
<p class="text-blue-700 text-sm mb-4">Validações típicas por mês:</p>
<ul class="text-sm text-blue-700 space-y-1 mb-4">
<li>• 200 extrações de nome (×2 cred = 400 cred)</li>
<li>• 200 validações de CPF (×1 cred = 200 cred)</li>
<li>• 100 análises de contexto reply (×5 cred = 500 cred)</li>
<li class="font-bold mt-2">• Total: 1.100 créditos = cabe no Free!</li>
</ul>
<p class="text-blue-800 text-sm">
Cresceu? Com o Starter (R$ 29/mês), o mesmo bot atende 10× mais clientes
pelo custo de <strong>R$ 0,0019 por validação</strong>.
</p>
</div>
<div class="bg-amber-50 border border-amber-100 rounded-2xl p-5 text-center">
<p class="text-amber-800 font-medium">
O café que você tomou hoje custou mais que 1.000 validações.
</p>
</div>
</div>
</section>
<!-- FAQ Pricing -->
<section class="py-12 bg-slate-50">
<div class="max-w-3xl mx-auto px-4 sm:px-6">
<h2 class="text-2xl font-bold text-center mb-8">Dúvidas sobre preço</h2>
<div class="space-y-4">
<div class="bg-white rounded-xl border border-gray-100 p-5">
<div class="font-semibold text-gray-800 mb-2">E se meus créditos acabarem?</div>
<div class="text-gray-500 text-sm">Chamadas retornam 429 com sugestão de upgrade. Sem cobrança surpresa. Seus dados e chaves continuam intactos.</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5">
<div class="font-semibold text-gray-800 mb-2">Por que validate_reply custa 5 créditos?</div>
<div class="text-gray-500 text-sm">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.</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5">
<div class="font-semibold text-gray-800 mb-2">Posso mudar de plano?</div>
<div class="text-gray-500 text-sm">Sim, a qualquer momento. Upgrade é imediato. Downgrade no próximo ciclo de cobrança.</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5">
<div class="font-semibold text-gray-800 mb-2">Aceita Pix ou boleto?</div>
<div class="text-gray-500 text-sm">Aceitamos cartão de crédito e débito via Stripe. Pix em breve.</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-5">
<div class="font-semibold text-gray-800 mb-2">Tem desconto anual?</div>
<div class="text-gray-500 text-sm">Em breve (fase 2). Cadastre-se para ser notificado.</div>
</div>
</div>
</div>
</section>
<!-- CTA final -->
<section class="py-16 bg-nalu-600 text-white text-center">
<div class="max-w-xl mx-auto px-4 sm:px-6">
<h2 class="text-2xl font-bold mb-3">Comece com 3.000 créditos grátis</h2>
<p class="text-nalu-100 mb-6">Sem cartão. Sem prazo. Setup em 30 segundos.</p>
<a href="/login" class="bg-white text-nalu-600 font-bold px-8 py-3 rounded-xl hover:bg-nalu-50 transition-colors inline-block">
Criar conta grátis →
</a>
</div>
</section>

View File

@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Nalu.Web.Pages;
public class PrecosModel : PageModel
{
public void OnGet() { }
}

View File

@ -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.";
}
<!-- Header do validador -->
<section class="bg-gradient-to-b from-slate-50 to-white pt-16 pb-10">
<div class="max-w-4xl mx-auto px-4 sm:px-6">
<div class="flex items-center gap-3 mb-4">
<a href="/validadores" class="text-gray-400 hover:text-nalu-600 text-sm">← Validadores</a>
</div>
<div class="flex items-start gap-4">
<div class="text-5xl">🧠</div>
<div>
<div class="flex items-center gap-3 mb-2">
<h1 class="text-3xl font-extrabold text-gray-900">validate_reply</h1>
<span class="bg-nalu-100 text-nalu-700 text-sm font-bold px-3 py-1 rounded-full">⭐ Premium · 5 créditos</span>
<span class="bg-green-100 text-green-700 text-xs font-semibold px-2 py-1 rounded-full">NEW</span>
</div>
<p class="text-xl text-gray-500">Análise de contexto conversacional.</p>
</div>
</div>
</div>
</section>
<!-- O que faz -->
<section class="py-10 bg-white">
<div class="max-w-4xl mx-auto px-4 sm:px-6 prose prose-gray max-w-none">
<h2 class="text-2xl font-bold mb-4">O que faz</h2>
<p class="text-gray-600 leading-relaxed">
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.
</p>
<h3 class="text-xl font-bold mt-8 mb-4">Resolve problemas como</h3>
<ul class="space-y-2 text-gray-600">
<li>✓ <strong>"Bora em 48?"</strong> → 48 parcelas, não R$48</li>
<li>✓ <strong>"É 200"</strong> → número do endereço, não R$200</li>
<li>✓ <strong>"Prefiro quinta"</strong> → quinta-feira, não 5 de algo</li>
<li>✓ <strong>"Não aguento mais"</strong> → handoff detectado</li>
<li>✓ <strong>"Quero o de cima"</strong> → upgrade de plano</li>
</ul>
</div>
</section>
<!-- Tipos de resposta -->
<section class="py-10 bg-slate-50">
<div class="max-w-4xl mx-auto px-4 sm:px-6">
<h2 class="text-2xl font-bold mb-6">10 tipos de resposta detectados</h2>
<div class="grid sm:grid-cols-2 gap-3">
@foreach (var rt in Model.ReplyTypes)
{
<div class="bg-white rounded-xl border border-gray-100 p-4">
<div class="flex items-start gap-3">
<code class="bg-nalu-50 text-nalu-700 text-xs font-bold px-2 py-1 rounded">@rt.Type</code>
<div>
<div class="font-semibold text-gray-800 text-sm">@rt.Label</div>
<div class="text-gray-500 text-xs mt-1">@rt.Example</div>
</div>
</div>
</div>
}
</div>
</div>
</section>
<!-- Input/Output -->
<section class="py-10 bg-white">
<div class="max-w-4xl mx-auto px-4 sm:px-6">
<h2 class="text-2xl font-bold mb-6">Input / Output</h2>
<div class="grid md:grid-cols-2 gap-6">
<div>
<div class="text-sm font-semibold text-gray-600 mb-2">Input</div>
<pre class="bg-slate-900 text-slate-300 rounded-xl p-5 text-sm overflow-x-auto">{
"agent_message": "Posso parcelar em 20x de R$100. Topa?",
"user_reply": "Bora em 48?",
"language": "pt-BR"
}</pre>
<p class="text-xs text-gray-400 mt-2">Nota: este validador usa <code>agent_message</code> + <code>user_reply</code> em vez de <code>agent_input</code> + <code>user_input</code>.</p>
</div>
<div>
<div class="text-sm font-semibold text-gray-600 mb-2">Output</div>
<pre class="bg-slate-900 text-slate-300 rounded-xl p-5 text-sm overflow-x-auto">{
"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?"
}</pre>
</div>
</div>
</div>
</section>
<!-- Exemplos do playground -->
<section class="py-10 bg-slate-50">
<div class="max-w-4xl mx-auto px-4 sm:px-6">
<h2 class="text-2xl font-bold mb-6">Exemplos</h2>
<div class="space-y-4">
@foreach (var ex in Model.Examples)
{
<div class="bg-white rounded-xl border border-gray-100 p-5">
<div class="flex items-center justify-between mb-3">
<div class="font-semibold text-gray-800">@ex.Label</div>
<code class="bg-nalu-50 text-nalu-700 text-xs font-bold px-2 py-1 rounded">@ex.ExpectedType</code>
</div>
<div class="font-mono text-sm space-y-1">
<div><span class="text-gray-400">Agente:</span> @ex.AgentMessage</div>
<div><span class="text-gray-400">Usuário:</span> @ex.UserReply</div>
</div>
@if (ex.Note != null)
{
<div class="mt-2 text-xs text-amber-600 bg-amber-50 px-3 py-1.5 rounded-lg">💡 @ex.Note</div>
}
</div>
}
</div>
</div>
</section>
<!-- Custo -->
<section class="py-10 bg-white">
<div class="max-w-4xl mx-auto px-4 sm:px-6">
<div class="bg-nalu-50 border border-nalu-100 rounded-2xl p-6 text-center">
<div class="text-2xl font-bold text-nalu-700 mb-2">5 créditos por chamada</div>
<div class="text-gray-600 space-y-1 text-sm">
<p>No plano Starter: <strong class="text-nalu-600">R$ 0,0097</strong> por análise.</p>
<p>No plano Pro: <strong class="text-nalu-600">R$ 0,0040</strong> por análise.</p>
<p class="text-gray-400 mt-2">Menos de 1 centavo para entender o que o cliente realmente quis dizer.</p>
</div>
<a href="/playground?validator=reply" class="inline-block mt-4 bg-nalu-600 text-white px-6 py-2.5 rounded-xl font-semibold text-sm hover:bg-nalu-700 transition-colors">
Testar no playground →
</a>
</div>
</div>
</section>

View File

@ -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<ReplyTypeCard> 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<ExampleCard> 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() { }
}

View File

@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] NALU AI</title>
<meta name="description" content="@(ViewData["Description"] ?? "NALU AI — Natural Language Understanding para chatbots. Extrai dados reais de diálogos agente/usuário.")" />
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Inter', 'sans-serif'] },
colors: {
nalu: {
50: '#f0f9ff',
100: '#e0f2fe',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
900: '#0c4a6e',
}
}
}
}
}
</script>
@await RenderSectionAsync("Head", required: false)
</head>
<body class="font-sans bg-white text-gray-900 antialiased">
<!-- Nav -->
<nav class="sticky top-0 z-50 bg-white border-b border-gray-100 shadow-sm">
<div class="max-w-6xl mx-auto px-4 sm:px-6 flex h-14 items-center justify-between">
<a href="/" class="flex items-center gap-1 font-bold text-lg">
<span class="text-gray-300">NA</span><span class="text-nalu-600">LU</span>
<span class="text-gray-500 font-normal text-sm ml-1">AI</span>
</a>
<div class="hidden sm:flex items-center gap-6 text-sm font-medium text-gray-600">
<a href="/validadores" class="hover:text-nalu-600 transition-colors">Validadores</a>
<a href="/playground" class="hover:text-nalu-600 transition-colors">Playground</a>
<a href="/docs" class="hover:text-nalu-600 transition-colors">Docs</a>
<a href="/precos" class="hover:text-nalu-600 transition-colors">Preços</a>
</div>
<div class="flex items-center gap-3">
@if (User.Identity?.IsAuthenticated == true)
{
var picture = User.FindFirst("picture")?.Value;
var uname = User.Identity.Name ?? User.FindFirst(ClaimTypes.Email)?.Value ?? "Usuário";
<a href="/painel" class="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-nalu-600">
@if (!string.IsNullOrEmpty(picture))
{
<img src="@picture" alt="avatar" class="w-7 h-7 rounded-full border border-gray-200" />
}
<span class="hidden sm:inline">@uname.Split(' ')[0]</span>
<span class="text-gray-400"></span> Painel
</a>
}
else
{
<a href="/login" class="text-sm font-medium text-gray-600 hover:text-nalu-600">Entrar</a>
<a href="/login" class="bg-nalu-600 text-white text-sm font-semibold px-4 py-2 rounded-lg hover:bg-nalu-700 transition-colors">
Começar grátis
</a>
}
</div>
</div>
</nav>
@RenderBody()
<!-- Footer -->
<footer class="bg-gray-50 border-t border-gray-100 mt-24">
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-12 grid grid-cols-2 sm:grid-cols-4 gap-8 text-sm text-gray-600">
<div>
<div class="font-bold text-gray-900 mb-3">
<span class="text-gray-300">NA</span><span class="text-nalu-600">LU</span> AI
</div>
<p class="text-gray-500 text-xs leading-relaxed">Natural Language Understanding para chatbots.</p>
</div>
<div>
<div class="font-semibold text-gray-900 mb-3">Produto</div>
<ul class="space-y-2">
<li><a href="/validadores" class="hover:text-nalu-600">Validadores</a></li>
<li><a href="/playground" class="hover:text-nalu-600">Playground</a></li>
<li><a href="/precos" class="hover:text-nalu-600">Preços</a></li>
</ul>
</div>
<div>
<div class="font-semibold text-gray-900 mb-3">Docs</div>
<ul class="space-y-2">
<li><a href="/docs/quickstart" class="hover:text-nalu-600">Quickstart</a></li>
<li><a href="/docs/api-reference" class="hover:text-nalu-600">API Reference</a></li>
<li><a href="/docs/mcp" class="hover:text-nalu-600">MCP Server</a></li>
</ul>
</div>
<div>
<div class="font-semibold text-gray-900 mb-3">Casos</div>
<ul class="space-y-2">
<li><a href="/casos/parcelas-48x" class="hover:text-nalu-600">Parcelas 48x</a></li>
<li><a href="/casos/extrator-de-nome" class="hover:text-nalu-600">Extrator de nome</a></li>
<li><a href="/casos/cep-via-conversa" class="hover:text-nalu-600">CEP via conversa</a></li>
</ul>
</div>
</div>
<div class="border-t border-gray-200 py-4 text-center text-xs text-gray-400">
© @DateTime.UtcNow.Year NALU AI. Todos os direitos reservados.
</div>
</footer>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@ -0,0 +1,4 @@
@using Nalu.Web.Pages
@using System.Security.Claims
@namespace Nalu.Web.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View File

@ -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<string>(), 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);
}
}

View File

@ -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<string> 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<string>(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));
}
}

View File

@ -0,0 +1,57 @@
namespace Nalu.Web.PostProcessors;
public class CorrectEmailTypos : IPostProcessor
{
public string Name => "correct_email_typos";
private static readonly Dictionary<string, string> 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);
}
}

View File

@ -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..]}");
}
}

View File

@ -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<string> 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);
}

View File

@ -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).");
}
}

View File

@ -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);
}

View File

@ -0,0 +1,31 @@
using System.Text.RegularExpressions;
namespace Nalu.Web.PostProcessors;
public class NormalizePostalCode : IPostProcessor
{
public string Name => "normalize_postal_code";
// Accepts: 310 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);
}
}

View File

@ -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<string, int> 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<string, int> 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<string, int> 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;
}
}

View File

@ -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<string> 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);
}
}

View File

@ -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<string>() ?? "none";
var isThreat = obj["is_threat"]?.GetValue<bool>() ?? 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);
}
}
}

View File

@ -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<bool>() ?? false;
if (!wantsHuman)
return ProcessorResult.WithOverride(value, "when_not_handoff");
var urgency = obj["urgency"]?.GetValue<string>() ?? "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);
}
}
}

View File

@ -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;
}
}

View File

@ -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';
}
}

266
src/Nalu.Api/Program.cs Normal file
View File

@ -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<IMongoClient>(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<MongoDbContext>();
// ── Repositories ──────────────────────────────────────────────────────────────
builder.Services.AddSingleton<ApiKeyRepository>();
builder.Services.AddSingleton<UserRepository>();
builder.Services.AddSingleton<UsageRepository>();
builder.Services.AddSingleton<SubscriptionRepository>();
builder.Services.AddSingleton<WebhookEventRepository>();
// ── 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<AuthenticationSchemeOptions, NaluAuthHandler>(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<GroqClient>(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<GroqProvider>(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<OpenRouterProvider>(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<GoogleAiProvider>(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<ILlmRouter>(sp =>
{
var providers = new List<ILlmProvider>
{
sp.GetRequiredService<GroqProvider>(),
sp.GetRequiredService<OpenRouterProvider>(),
sp.GetRequiredService<GoogleAiProvider>()
};
return new LlmRouter(providers, sp.GetRequiredService<ILogger<LlmRouter>>());
});
// ViaCEP enricher — no fixed base address (calls multiple URLs)
builder.Services.AddHttpClient<ViaCepEnricher>(client =>
{
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.Add("User-Agent", "nalu-ai/1.0");
});
builder.Services.AddTransient<IEnricher>(sp => sp.GetRequiredService<ViaCepEnricher>());
// ── Post-processors ───────────────────────────────────────────────────────────
builder.Services.AddSingleton<IPostProcessor, CapitalizeProperName>();
builder.Services.AddSingleton<IPostProcessor, RemoveTitles>();
builder.Services.AddSingleton<IPostProcessor, ValidateCpfDigit>();
builder.Services.AddSingleton<IPostProcessor, FormatPhone>();
builder.Services.AddSingleton<IPostProcessor, FormatCep>();
builder.Services.AddSingleton<IPostProcessor, CorrectEmailTypos>();
builder.Services.AddSingleton<IPostProcessor, NormalizePostalCode>();
builder.Services.AddSingleton<IPostProcessor, ParseDate>();
builder.Services.AddSingleton<IPostProcessor, CalculateAge>();
builder.Services.AddSingleton<IPostProcessor, ValidateCnpjDigit>();
builder.Services.AddSingleton<IPostProcessor, FormatPlate>();
builder.Services.AddSingleton<IPostProcessor, SelectHandoffSuggestion>();
builder.Services.AddSingleton<IPostProcessor, SelectCancelSuggestion>();
// ── Domain services ──────────────────────────────────────────────────────────
builder.Services.AddScoped<UserService>();
builder.Services.AddSingleton<ValidatorLoader>();
builder.Services.AddSingleton<DeterministicLayer>();
builder.Services.AddSingleton<PostProcessorRegistry>();
builder.Services.AddSingleton<SuggestionBuilder>();
builder.Services.AddSingleton<CacheService>();
builder.Services.AddSingleton<AuthService>();
builder.Services.AddSingleton<RateLimitService>();
builder.Services.AddSingleton<CreditService>();
// EnrichmentService is Scoped because ViaCepEnricher is Transient (HttpClient lifecycle)
builder.Services.AddScoped<EnrichmentService>();
builder.Services.AddScoped<LlmExtractionService>();
builder.Services.AddScoped<ExtractionPipeline>();
builder.Services.AddScoped<ReplyService>();
// ── MCP server ────────────────────────────────────────────────────────────────
builder.Services.AddSingleton<McpServer>();
// ── App ───────────────────────────────────────────────────────────────────────
var app = builder.Build();
// Initialize MongoDB indexes on startup
var mongo = app.Services.GetRequiredService<MongoDbContext>();
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 { }

View File

@ -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"
}
}
}
}

View File

@ -0,0 +1,31 @@
using Nalu.Web.Data.Models;
using Nalu.Web.Data.Repositories;
using Nalu.Web.Infrastructure;
namespace Nalu.Web.Services;
/// <summary>
/// Validates API keys. Checks MongoDB first, falls back to config for test/bootstrap keys.
/// </summary>
public class AuthService(ApiKeyRepository apiKeyRepo, IConfiguration config)
{
private readonly List<ApiKeyConfig> _configKeys =
config.GetSection("ApiKeys").Get<List<ApiKeyConfig>>() ?? [];
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;
}
}

View File

@ -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<int>("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);
}
}

View File

@ -0,0 +1,55 @@
namespace Nalu.Web.Services;
/// <summary>
/// Credit cost per validator. 1 = deterministic, 2 = light LLM, 5 = heavy LLM (70B context-aware).
/// </summary>
public static class CreditCosts
{
private static readonly Dictionary<string, int> _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<string, string> _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;
}

View File

@ -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<CreditConsumeResult> 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<UsageMonthly>.Filter.And(
Builders<UsageMonthly>.Filter.Eq(u => u.ApiKey, apiKey),
Builders<UsageMonthly>.Filter.Eq(u => u.YearMonth, yearMonth));
var update = Builders<UsageMonthly>.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<UsageMonthly>
{
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<int?>($"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..];
}

View File

@ -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<string, string> 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;
}
}

View File

@ -0,0 +1,32 @@
using Nalu.Web.Enrichers;
using Nalu.Web.Models;
namespace Nalu.Web.Services;
public class EnrichmentService
{
private readonly Dictionary<string, IEnricher> _enrichers;
public EnrichmentService(IEnumerable<IEnricher> enrichers)
{
_enrichers = enrichers.ToDictionary(e => e.Name, StringComparer.OrdinalIgnoreCase);
}
public async Task<EnrichmentResult> 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);
}
}

View File

@ -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<ExtractionResponse> 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<ExtractionResponse> 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<ExtractionResponse> 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);
}
}

View File

@ -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<LlmExtractionResult> 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<LlmJsonResponse>(
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; }
}
}

View File

@ -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;
/// <summary>
/// Google AI Studio via OpenAI-compatible endpoint.
/// </summary>
public class GoogleAiProvider(HttpClient http, IConfiguration config, ILogger<GoogleAiProvider> logger) : ILlmProvider
{
public string Name => "google-ai";
public async Task<LlmResponse> 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<string>();
return new LlmResponse
{
Content = content ?? throw new InvalidOperationException("Empty content in Google AI response"),
Provider = Name,
LatencyMs = (int)sw.ElapsedMilliseconds
};
}
}

View File

@ -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<GroqProvider> logger) : ILlmProvider
{
public string Name => "groq";
public async Task<LlmResponse> 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<string> 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<string>();
return content ?? throw new InvalidOperationException("Empty content in Groq response");
}
}

View File

@ -0,0 +1,7 @@
namespace Nalu.Web.Services.LlmRouter;
public interface ILlmProvider
{
string Name { get; }
Task<LlmResponse> CompleteAsync(LlmRequest request, CancellationToken ct);
}

View File

@ -0,0 +1,6 @@
namespace Nalu.Web.Services.LlmRouter;
public interface ILlmRouter
{
Task<LlmResponse> CompleteAsync(LlmRequest request, CancellationToken ct);
}

View File

@ -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);

View File

@ -0,0 +1,29 @@
namespace Nalu.Web.Services.LlmRouter;
public class LlmRouter(IReadOnlyList<ILlmProvider> providers, ILogger<LlmRouter> logger) : ILlmRouter
{
public async Task<LlmResponse> 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");
}
}

View File

@ -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<OpenRouterProvider> logger) : ILlmProvider
{
public string Name => "openrouter";
public async Task<LlmResponse> 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<string>();
return new LlmResponse
{
Content = content ?? throw new InvalidOperationException("Empty content in OpenRouter response"),
Provider = Name,
LatencyMs = (int)sw.ElapsedMilliseconds
};
}
}

View File

@ -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<string, IPostProcessor> _processors;
public PostProcessorRegistry(IEnumerable<IPostProcessor> processors)
{
_processors = processors.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase);
}
public PostProcessorOutput Apply(IReadOnlyList<string> 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
};
}
}

View File

@ -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<string, InMemoryUsage> _fallback = new();
/// <summary>
/// Returns true and increments counters if the key is within limits.
/// Uses MongoDB when connected; falls back to in-memory.
/// </summary>
public async Task<bool> TryConsumeAsync(string apiKey, string plan, CancellationToken ct = default)
{
var planSection = config.GetSection($"Plans:{plan}");
var dailyLimit = planSection.GetValue<int?>("daily_limit");
var monthlyLimit = planSection.GetValue<int?>("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<int?>("daily_limit");
var monthlyLimit = planSection.GetValue<int?>("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);
}

View File

@ -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<ReplyService> 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<ReplyResponse> 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<LlmReplyJson>(
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; }
}
}

View File

@ -0,0 +1,119 @@
using System.Text.Json.Nodes;
using Nalu.Web.Models;
namespace Nalu.Web.Services;
public class SuggestionBuilder
{
private static readonly Dictionary<string, string> 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<string>() ?? "");
}
}
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 "";
}
}

View File

@ -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<UserService> logger)
{
public record LoginResult(NaluUser User, ApiKey ApiKey, bool IsNew);
public async Task<LoginResult> 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<ApiKey> 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());
}
}

View File

@ -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<ValidatorLoader> _logger;
private readonly ConcurrentDictionary<string, ValidatorDefinition> _cache = new();
private FileSystemWatcher? _watcher;
public ValidatorLoader(ILogger<ValidatorLoader> 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<ValidatorDefinition> LoadAll()
{
if (!Directory.Exists(_validatorsPath))
return [];
return Directory
.GetFiles(_validatorsPath, "*.md")
.Select(f => Load(Path.GetFileNameWithoutExtension(f)));
}
/// <summary>Parses a validator .md string. Public for unit testing.</summary>
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<string, string>(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<string> 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;
}
}

View File

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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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?

View File

@ -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)

View File

@ -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)

View File

@ -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)

Some files were not shown because too many files have changed in this diff Show More