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:
commit
ea6cdb5395
24
.claude/settings.local.json
Normal file
24
.claude/settings.local.json
Normal 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
73
.github/workflows/deploy.yml
vendored
Normal 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
79
.gitignore
vendored
Normal 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
40
CLAUDE.md
Normal 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
26
Dockerfile
Normal 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
25
Nalu.sln
Normal 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
838
Prompt1.md
Normal 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
|
||||
|
||||
{
|
||||
|
||||
  "agent\_input": "Bom dia! Qual seu nome?",
|
||||
|
||||
  "user\_input": "Bom dia!",
|
||||
|
||||
  "agent\_context": "Negociador de parcelamento.",
|
||||
|
||||
  "language": "pt-BR"
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
\---
|
||||
|
||||
|
||||
|
||||
\## Arquitetura
|
||||
|
||||
|
||||
|
||||
\### Stack
|
||||
|
||||
\- ASP.NET Core 9 (C#), .NET 10
|
||||
|
||||
\- LLM principal: Groq (Llama 3.3 70B, free tier) via API compatível OpenAI
|
||||
|
||||
\- LLM fallback: OpenRouter (modelos baratos)
|
||||
|
||||
\- Cache: in-memory (V1)
|
||||
|
||||
\- Banco: PostgreSQL (logs, billing, API keys) — EF Core
|
||||
|
||||
\- Container Docker único
|
||||
|
||||
|
||||
|
||||
\### Pipeline de extração (compartilhado)
|
||||
|
||||
|
||||
|
||||
```
|
||||
|
||||
Request chega no endpoint específico (ex: /v1/extract/name)
|
||||
|
||||
  ↓
|
||||
|
||||
Auth (API key via header Authorization: Bearer {key})
|
||||
|
||||
  ↓
|
||||
|
||||
Rate limit por plano
|
||||
|
||||
  ↓
|
||||
|
||||
Resolve qual validador (.md) usar baseado no endpoint
|
||||
|
||||
  ↓
|
||||
|
||||
Cache lookup (hash de: validator\_id + agent\_input + user\_input + language)
|
||||
|
||||
  ↓ cache miss
|
||||
|
||||
Carregar validador (.md do disco, com cache em memória)
|
||||
|
||||
  ↓
|
||||
|
||||
Camada 1 — Regras determinísticas (regex, stop words, constraints)
|
||||
|
||||
  ↓ não resolveu
|
||||
|
||||
Camada 2 — LLM com prompt do validador (Groq → fallback OpenRouter)
|
||||
|
||||
  ↓
|
||||
|
||||
Pós-processamento (normalização, dígito verificador, etc.)
|
||||
|
||||
  ↓
|
||||
|
||||
Enriquecimento externo (ViaCEP etc., se o validador declarar)
|
||||
|
||||
  ↓
|
||||
|
||||
Montar sugestão de fala (template do validador)
|
||||
|
||||
  ↓
|
||||
|
||||
Salvar log + retornar resposta
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
\---
|
||||
|
||||
|
||||
|
||||
\## Estrutura de pastas
|
||||
|
||||
|
||||
|
||||
```
|
||||
|
||||
src/
|
||||
|
||||
  Nalu.Api/
|
||||
|
||||
  Program.cs
|
||||
|
||||
  appsettings.json
|
||||
|
||||
  appsettings.Development.json
|
||||
|
||||
|
||||
|
||||
  Endpoints/
|
||||
|
||||
  ExtractEndpoints.cs # Registra todos os POST /v1/extract/{tipo}
|
||||
|
||||
  ValidatorsEndpoints.cs # GET /v1/validators
|
||||
|
||||
|
||||
|
||||
  Models/
|
||||
|
||||
  ExtractionRequest.cs # Body compartilhado por todos os endpoints
|
||||
|
||||
  ExtractionResponse.cs # Response compartilhada
|
||||
|
||||
  ValidatorInfo.cs # Modelo para GET /v1/validators
|
||||
|
||||
  ValidatorDefinition.cs # Validador parseado do .md
|
||||
|
||||
|
||||
|
||||
  Services/
|
||||
|
||||
  ExtractionPipeline.cs # Orquestra o pipeline (recebe validator\_id)
|
||||
|
||||
  ValidatorLoader.cs # Parseia .md, cache em memória, FileSystemWatcher
|
||||
|
||||
  DeterministicLayer.cs # Camada 1: stop words, regex, constraints
|
||||
|
||||
  LlmExtractionService.cs # Camada 2: Groq/OpenRouter
|
||||
|
||||
  PostProcessorRegistry.cs # Registry de IPostProcessor por nome
|
||||
|
||||
  EnrichmentService.cs # Orquestra enrichers
|
||||
|
||||
  SuggestionBuilder.cs # Monta suggestion\_to\_agent
|
||||
|
||||
  CacheService.cs # IMemoryCache com hash
|
||||
|
||||
  AuthService.cs # Valida API key, retorna plano
|
||||
|
||||
  RateLimitService.cs # Rate limit por plano
|
||||
|
||||
|
||||
|
||||
  PostProcessors/
|
||||
|
||||
  IPostProcessor.cs
|
||||
|
||||
  CapitalizeProperName.cs
|
||||
|
||||
  RemoveTitles.cs
|
||||
|
||||
|
||||
|
||||
  Enrichers/
|
||||
|
||||
  IEnricher.cs
|
||||
|
||||
|
||||
|
||||
  Infrastructure/
|
||||
|
||||
  GroqClient.cs
|
||||
|
||||
  OpenRouterClient.cs
|
||||
|
||||
  NaluDbContext.cs
|
||||
|
||||
|
||||
|
||||
  Validators/ # ← CADA .md É UM VALIDADOR
|
||||
|
||||
  validate\_full\_name.md
|
||||
|
||||
|
||||
|
||||
tests/
|
||||
|
||||
  Nalu.Tests/
|
||||
|
||||
  ValidatorLoaderTests.cs
|
||||
|
||||
  DeterministicLayerTests.cs
|
||||
|
||||
  PipelineIntegrationTests.cs
|
||||
|
||||
|
||||
|
||||
Dockerfile
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
\### Registro dos endpoints (Minimal APIs)
|
||||
|
||||
|
||||
|
||||
```csharp
|
||||
|
||||
// ExtractEndpoints.cs
|
||||
|
||||
public static class ExtractEndpoints
|
||||
|
||||
{
|
||||
|
||||
  public static void MapExtractEndpoints(this WebApplication app)
|
||||
|
||||
  {
|
||||
|
||||
  var group = app.MapGroup("/v1/extract")
|
||||
|
||||
  .RequireAuthorization();
|
||||
|
||||
|
||||
|
||||
  group.MapPost("/name", async (ExtractionRequest req, ExtractionPipeline pipeline) =>
|
||||
|
||||
  await pipeline.ExecuteAsync("validate\_full\_name", req));
|
||||
|
||||
|
||||
|
||||
  // Prompt 2 adiciona os demais aqui — mesma estrutura, 2 linhas cada
|
||||
|
||||
  }
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
\---
|
||||
|
||||
|
||||
|
||||
\## Formato dos validadores (.md)
|
||||
|
||||
|
||||
|
||||
O `ValidatorLoader` parseia seções `##` do markdown. Cada seção vira uma propriedade do `ValidatorDefinition`.
|
||||
|
||||
|
||||
|
||||
\### Exemplo completo: `validate\_full\_name.md`
|
||||
|
||||
|
||||
|
||||
```markdown
|
||||
|
||||
\# validate\_full\_name
|
||||
|
||||
|
||||
|
||||
Extrai o nome completo do usuário a partir do diálogo.
|
||||
|
||||
|
||||
|
||||
\## config
|
||||
|
||||
|
||||
|
||||
\- type: extraction
|
||||
|
||||
\- version: 1.0
|
||||
|
||||
\- languages: pt-BR, es-ES, en-US
|
||||
|
||||
\- endpoint: /v1/extract/name
|
||||
|
||||
\- mcp\_tool: nalu\_extract\_name
|
||||
|
||||
\- mcp\_description: Extrai o nome completo do usuário a partir da conversa. Use quando o agente perguntou o nome e o usuário respondeu. Retorna o nome extraído, nível de certeza e sugestão de fala para o agente. Se certain=true, aceite o valor. Se certain=false e suggestion\_to\_agent não é null, use a sugestão como próxima mensagem. Se obtained=false, use a sugestão para re-pedir o dado.
|
||||
|
||||
|
||||
|
||||
\## deterministic\_rules
|
||||
|
||||
|
||||
|
||||
\### stop\_words
|
||||
|
||||
bom dia, boa tarde, boa noite, olá, oi, tudo bem, e aí, fala, eae, opa
|
||||
|
||||
|
||||
|
||||
\### reject\_patterns
|
||||
|
||||
\- ^(não|nao|sei la|sei lá|tanto faz|qualquer|nenhum|nada)$
|
||||
|
||||
\- ^\\d+$
|
||||
|
||||
|
||||
|
||||
\### accept\_patterns
|
||||
|
||||
\- ^meu nome é\\s+(.+)$
|
||||
|
||||
\- ^me chamo\\s+(.+)$
|
||||
|
||||
\- ^sou o\\s+(.+)$
|
||||
|
||||
\- ^sou a\\s+(.+)$
|
||||
|
||||
\- ^pode me chamar de\\s+(.+)$
|
||||
|
||||
|
||||
|
||||
\### constraints
|
||||
|
||||
\- min\_length: 2
|
||||
|
||||
\- must\_have\_alpha: true
|
||||
|
||||
\- max\_length: 120
|
||||
|
||||
|
||||
|
||||
\## prompt
|
||||
|
||||
|
||||
|
||||
Você é um extrator de nomes. Dado o diálogo abaixo entre um agente e um usuário, extraia o nome completo que o usuário informou.
|
||||
|
||||
|
||||
|
||||
Regras:
|
||||
|
||||
1\. Se o usuário respondeu com saudação (bom dia, oi, etc.) e NÃO disse o nome, retorne extracted\_value: null.
|
||||
|
||||
2\. Se o usuário deu um nome que parece falso, zueira ou ofensivo (ex: "Xilofone", "Ninguém", "Seu Pai", "Não tenho"), retorne extracted\_value com o nome mas certain: false.
|
||||
|
||||
3\. Se o usuário deu um nome comum/plausível, retorne extracted\_value com o nome e certain: true.
|
||||
|
||||
4\. Nomes incomuns mas reais (ex: "Céu", "Lua", "Sol", "Índigo") devem retornar certain: false para o agente confirmar.
|
||||
|
||||
5\. Normalize o nome com capitalização adequada (primeira letra maiúscula de cada palavra).
|
||||
|
||||
|
||||
|
||||
Diálogo:
|
||||
|
||||
Agente: {{agent\_input}}
|
||||
|
||||
Usuário: {{user\_input}}
|
||||
|
||||
|
||||
|
||||
Contexto do agente: {{agent\_context}}
|
||||
|
||||
|
||||
|
||||
Responda SOMENTE com JSON válido, sem markdown, sem explicação:
|
||||
|
||||
{
|
||||
|
||||
  "extracted\_value": "nome extraído ou null",
|
||||
|
||||
  "certain": true/false,
|
||||
|
||||
  "reasoning": "explicação curta de 1 linha"
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
\## few\_shot\_examples
|
||||
|
||||
|
||||
|
||||
\### example 1
|
||||
|
||||
\- agent\_input: Bom dia! Qual seu nome completo?
|
||||
|
||||
\- user\_input: Bom dia!
|
||||
|
||||
\- output: {"extracted\_value": null, "certain": false, "reasoning": "Usuário respondeu com saudação, não informou o nome"}
|
||||
|
||||
|
||||
|
||||
\### example 2
|
||||
|
||||
\- agent\_input: Qual seu nome?
|
||||
|
||||
\- user\_input: Meu nome é xilofone
|
||||
|
||||
\- output: {"extracted\_value": "Xilofone", "certain": false, "reasoning": "Nome aparenta ser zueira, precisa confirmação"}
|
||||
|
||||
|
||||
|
||||
\### example 3
|
||||
|
||||
\- agent\_input: Para continuar, preciso do seu nome completo.
|
||||
|
||||
\- user\_input: Maria Silva dos Santos
|
||||
|
||||
\- output: {"extracted\_value": "Maria Silva Dos Santos", "certain": true, "reasoning": "Nome completo plausível informado diretamente"}
|
||||
|
||||
|
||||
|
||||
\### example 4
|
||||
|
||||
\- agent\_input: Qual seu nome?
|
||||
|
||||
\- user\_input: sei la
|
||||
|
||||
\- output: {"extracted\_value": null, "certain": false, "reasoning": "Usuário foi evasivo, não informou nome"}
|
||||
|
||||
|
||||
|
||||
\### example 5
|
||||
|
||||
\- agent\_input: Tem certeza que seu nome é Cebola?
|
||||
|
||||
\- user\_input: Sim, quero que me chame de Cebola.
|
||||
|
||||
\- output: {"extracted\_value": "Cebola", "certain": true, "reasoning": "Usuário confirmou o nome após questionamento"}
|
||||
|
||||
|
||||
|
||||
\### example 6
|
||||
|
||||
\- agent\_input: Qual seu nome?
|
||||
|
||||
\- user\_input: Céu Azul de Oliveira
|
||||
|
||||
\- output: {"extracted\_value": "Céu Azul De Oliveira", "certain": false, "reasoning": "Nome incomum, pode ser real mas precisa confirmação"}
|
||||
|
||||
|
||||
|
||||
\## post\_processors
|
||||
|
||||
\- capitalize\_proper\_name
|
||||
|
||||
\- remove\_titles
|
||||
|
||||
|
||||
|
||||
\## enrichment
|
||||
|
||||
(nenhum)
|
||||
|
||||
|
||||
|
||||
\## suggestions
|
||||
|
||||
|
||||
|
||||
\### when\_null\_greeting
|
||||
|
||||
{{greeting\_response}} Mas preciso do seu nome completo para continuar. Pode me dizer?
|
||||
|
||||
|
||||
|
||||
\### when\_null\_evasive
|
||||
|
||||
Sem problemas, mas preciso do seu nome para prosseguir. Qual seu nome completo?
|
||||
|
||||
|
||||
|
||||
\### when\_uncertain
|
||||
|
||||
Só confirmando: seu nome é {{extracted\_value}}? Pode confirmar?
|
||||
|
||||
|
||||
|
||||
\### when\_certain
|
||||
|
||||
(sem sugestão — agente segue o fluxo)
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
\---
|
||||
|
||||
|
||||
|
||||
\## Contrato da API
|
||||
|
||||
|
||||
|
||||
\### Request (mesmo para todos os endpoints)
|
||||
|
||||
|
||||
|
||||
```
|
||||
|
||||
POST /v1/extract/name
|
||||
|
||||
Authorization: Bearer {api\_key}
|
||||
|
||||
Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
```json
|
||||
|
||||
{
|
||||
|
||||
  "agent\_input": "Bom dia! Qual seu nome completo?",
|
||||
|
||||
  "user\_input": "Bom dia!",
|
||||
|
||||
  "agent\_context": "Negociador de parcelamento. Precisa de nome e CPF.",
|
||||
|
||||
  "language": "pt-BR"
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
\### Response (mesma estrutura para todos)
|
||||
|
||||
|
||||
|
||||
```json
|
||||
|
||||
{
|
||||
|
||||
  "obtained": false,
|
||||
|
||||
  "extracted\_value": null,
|
||||
|
||||
  "confidence": 0.15,
|
||||
|
||||
  "certain": false,
|
||||
|
||||
  "reasoning": "Usuário respondeu com saudação, não informou o nome",
|
||||
|
||||
  "suggestion\_to\_agent": "Bom dia! Mas preciso do seu nome completo para continuar. Pode me dizer?",
|
||||
|
||||
  "validator\_used": "validate\_full\_name",
|
||||
|
||||
  "engine": "deterministic"
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
\### Lógica obtained × certain × suggestion
|
||||
|
||||
|
||||
|
||||
| Cenário | obtained | certain | suggestion\_to\_agent |
|
||||
|
||||
|---|---|---|---|
|
||||
|
||||
| Valor extraído e confiável | true | true | null |
|
||||
|
||||
| Valor extraído mas duvidoso | true | false | Frase de confirmação |
|
||||
|
||||
| Nada extraído (saudação, evasão) | false | false | Frase re-pedindo o dado |
|
||||
|
||||
| Erro de formato (CPF inválido) | false | false | Frase explicando o erro |
|
||||
|
||||
|
||||
|
||||
\### GET /v1/validators
|
||||
|
||||
|
||||
|
||||
```json
|
||||
|
||||
{
|
||||
|
||||
  "validators": \[
|
||||
|
||||
  {
|
||||
|
||||
  "id": "validate\_full\_name",
|
||||
|
||||
  "endpoint": "/v1/extract/name",
|
||||
|
||||
  "mcp\_tool": "nalu\_extract\_name",
|
||||
|
||||
  "description": "Extrai o nome completo do usuário",
|
||||
|
||||
  "version": "1.0",
|
||||
|
||||
  "languages": \["pt-BR", "es-ES", "en-US"]
|
||||
|
||||
  }
|
||||
|
||||
  ]
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
\---
|
||||
|
||||
|
||||
|
||||
\## O que gerar
|
||||
|
||||
|
||||
|
||||
1\. Projeto ASP.NET Core 9 com Minimal APIs e a estrutura de pastas acima
|
||||
|
||||
2\. `ExtractEndpoints.cs` com `POST /v1/extract/name`
|
||||
|
||||
3\. `ValidatorsEndpoints.cs` com `GET /v1/validators`
|
||||
|
||||
4\. `ExtractionPipeline.cs` — orquestra o pipeline, recebe `validator\_id` + `ExtractionRequest`
|
||||
|
||||
5\. `ValidatorLoader.cs` — parseia `.md`, `ConcurrentDictionary` + `FileSystemWatcher`
|
||||
|
||||
6\. `DeterministicLayer.cs` — stop\_words, reject/accept patterns, constraints
|
||||
|
||||
7\. `LlmExtractionService.cs` — monta messages e chama Groq `/chat/completions` com `response\_format: json\_object`
|
||||
|
||||
8\. `PostProcessorRegistry.cs` + `CapitalizeProperName.cs` + `RemoveTitles.cs`
|
||||
|
||||
9\. `SuggestionBuilder.cs` — templates com `{{extracted\_value}}`, `{{greeting\_response}}`
|
||||
|
||||
10\. `CacheService.cs` — `IMemoryCache`, TTL configurável
|
||||
|
||||
11\. `AuthService.cs` — valida API key contra lista em appsettings (sem banco por agora)
|
||||
|
||||
12\. `RateLimitService.cs` — rate limit por plano
|
||||
|
||||
13\. `GroqClient.cs` + `OpenRouterClient.cs` — HttpClients tipados, fallback automático em 429/5xx
|
||||
|
||||
14\. `validate\_full\_name.md` completo
|
||||
|
||||
15\. Testes para ValidatorLoader, DeterministicLayer, integração do pipeline
|
||||
|
||||
16\. `appsettings.json` com Groq, OpenRouter, planos, cache, API keys de teste
|
||||
|
||||
17\. `Dockerfile`
|
||||
|
||||
18\. Models (ExtractionRequest, ExtractionResponse, ValidatorInfo, ValidatorDefinition)
|
||||
|
||||
|
||||
|
||||
\### appsettings.json
|
||||
|
||||
|
||||
|
||||
```json
|
||||
|
||||
{
|
||||
|
||||
  "Groq": {
|
||||
|
||||
  "ApiKey": "YOUR\_GROQ\_API\_KEY",
|
||||
|
||||
  "BaseUrl": "https://api.groq.com/openai/v1",
|
||||
|
||||
  "Model": "llama-3.3-70b-versatile",
|
||||
|
||||
  "MaxTokens": 500,
|
||||
|
||||
  "Temperature": 0.1
|
||||
|
||||
  },
|
||||
|
||||
  "OpenRouter": {
|
||||
|
||||
  "ApiKey": "YOUR\_OPENROUTER\_API\_KEY",
|
||||
|
||||
  "BaseUrl": "https://openrouter.ai/api/v1",
|
||||
|
||||
  "Model": "mistralai/mistral-7b-instruct",
|
||||
|
||||
  "MaxTokens": 500,
|
||||
|
||||
  "Temperature": 0.1
|
||||
|
||||
  },
|
||||
|
||||
  "Plans": {
|
||||
|
||||
  "free": { "monthly\_limit": 2000, "daily\_limit": 100 },
|
||||
|
||||
  "hobby": { "monthly\_limit": 5000, "daily\_limit": null },
|
||||
|
||||
  "indie": { "monthly\_limit": 25000, "daily\_limit": null },
|
||||
|
||||
  "pro": { "monthly\_limit": 100000, "daily\_limit": null }
|
||||
|
||||
  },
|
||||
|
||||
  "Cache": { "DefaultTtlMinutes": 60 },
|
||||
|
||||
  "ApiKeys": \[
|
||||
|
||||
  { "key": "nalu-test-key-001", "plan": "free", "owner": "test" }
|
||||
|
||||
  ]
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
\### Notas de implementação
|
||||
|
||||
\- `ValidatorLoader`: regex por seção `##`. `ConcurrentDictionary` como cache. `FileSystemWatcher` invalida ao mudar `.md`.
|
||||
|
||||
\- `GroqClient`: `/chat/completions`, system message = prompt + few-shot, user message = diálogo. `response\_format: { type: "json\_object" }`.
|
||||
|
||||
\- Fallback: Groq 429 ou 5xx → retry no OpenRouter.
|
||||
|
||||
\- `SuggestionBuilder`: detectar saudação no `user\_input` para `{{greeting\_response}}`.
|
||||
|
||||
\- DI para tudo no `Program.cs`.
|
||||
|
||||
|
||||
|
||||
\### NÃO incluir
|
||||
|
||||
\- MCP server (Prompt 2)
|
||||
|
||||
\- Validadores além do validate\_full\_name (Prompt 2)
|
||||
|
||||
\- Painel admin, billing, PostgreSQL
|
||||
|
||||
242
Prompt2.md
Normal file
242
Prompt2.md
Normal 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
55
docker-compose.yml
Normal 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
130
docs/site-brief.md
Normal 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
10
mcp-config.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"nalu": {
|
||||
"url": "http://localhost:5282/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer nalu-test-key-001"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/Nalu.Api/Data/Models/ApiKey.cs
Normal file
35
src/Nalu.Api/Data/Models/ApiKey.cs
Normal 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; }
|
||||
}
|
||||
41
src/Nalu.Api/Data/Models/NaluUser.cs
Normal file
41
src/Nalu.Api/Data/Models/NaluUser.cs
Normal 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;
|
||||
}
|
||||
42
src/Nalu.Api/Data/Models/Subscription.cs
Normal file
42
src/Nalu.Api/Data/Models/Subscription.cs
Normal 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;
|
||||
}
|
||||
33
src/Nalu.Api/Data/Models/UsageDaily.cs
Normal file
33
src/Nalu.Api/Data/Models/UsageDaily.cs
Normal 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"
|
||||
}
|
||||
42
src/Nalu.Api/Data/Models/UsageMonthly.cs
Normal file
42
src/Nalu.Api/Data/Models/UsageMonthly.cs
Normal 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;
|
||||
}
|
||||
27
src/Nalu.Api/Data/Models/WebhookEvent.cs
Normal file
27
src/Nalu.Api/Data/Models/WebhookEvent.cs
Normal 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;
|
||||
}
|
||||
116
src/Nalu.Api/Data/MongoDbContext.cs
Normal file
116
src/Nalu.Api/Data/MongoDbContext.cs
Normal 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 }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
48
src/Nalu.Api/Data/Repositories/ApiKeyRepository.cs
Normal file
48
src/Nalu.Api/Data/Repositories/ApiKeyRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
46
src/Nalu.Api/Data/Repositories/SubscriptionRepository.cs
Normal file
46
src/Nalu.Api/Data/Repositories/SubscriptionRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
68
src/Nalu.Api/Data/Repositories/UsageRepository.cs
Normal file
68
src/Nalu.Api/Data/Repositories/UsageRepository.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
61
src/Nalu.Api/Data/Repositories/UserRepository.cs
Normal file
61
src/Nalu.Api/Data/Repositories/UserRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
40
src/Nalu.Api/Data/Repositories/WebhookEventRepository.cs
Normal file
40
src/Nalu.Api/Data/Repositories/WebhookEventRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
232
src/Nalu.Api/Endpoints/ExtractEndpoints.cs
Normal file
232
src/Nalu.Api/Endpoints/ExtractEndpoints.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
31
src/Nalu.Api/Endpoints/ValidatorsEndpoints.cs
Normal file
31
src/Nalu.Api/Endpoints/ValidatorsEndpoints.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
16
src/Nalu.Api/Enrichers/IEnricher.cs
Normal file
16
src/Nalu.Api/Enrichers/IEnricher.cs
Normal 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);
|
||||
}
|
||||
98
src/Nalu.Api/Enrichers/ViaCepEnricher.cs
Normal file
98
src/Nalu.Api/Enrichers/ViaCepEnricher.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
105
src/Nalu.Api/Infrastructure/GroqClient.cs
Normal file
105
src/Nalu.Api/Infrastructure/GroqClient.cs
Normal 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" };
|
||||
}
|
||||
}
|
||||
82
src/Nalu.Api/Infrastructure/NaluAuthHandler.cs
Normal file
82
src/Nalu.Api/Infrastructure/NaluAuthHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
65
src/Nalu.Api/Infrastructure/OpenRouterClient.cs
Normal file
65
src/Nalu.Api/Infrastructure/OpenRouterClient.cs
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
222
src/Nalu.Api/Mcp/McpServer.cs
Normal file
222
src/Nalu.Api/Mcp/McpServer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
18
src/Nalu.Api/Models/ExtractionRequest.cs
Normal file
18
src/Nalu.Api/Models/ExtractionRequest.cs
Normal 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";
|
||||
}
|
||||
29
src/Nalu.Api/Models/ExtractionResponse.cs
Normal file
29
src/Nalu.Api/Models/ExtractionResponse.cs
Normal 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; }
|
||||
}
|
||||
15
src/Nalu.Api/Models/ReplyRequest.cs
Normal file
15
src/Nalu.Api/Models/ReplyRequest.cs
Normal 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";
|
||||
}
|
||||
50
src/Nalu.Api/Models/ReplyResponse.cs
Normal file
50
src/Nalu.Api/Models/ReplyResponse.cs
Normal 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;
|
||||
}
|
||||
41
src/Nalu.Api/Models/ValidatorDefinition.cs
Normal file
41
src/Nalu.Api/Models/ValidatorDefinition.cs
Normal 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; }
|
||||
}
|
||||
24
src/Nalu.Api/Models/ValidatorInfo.cs
Normal file
24
src/Nalu.Api/Models/ValidatorInfo.cs
Normal 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; }
|
||||
}
|
||||
24
src/Nalu.Api/Nalu.Web.csproj
Normal file
24
src/Nalu.Api/Nalu.Web.csproj
Normal 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>
|
||||
2
src/Nalu.Api/Pages/Auth/Callback.cshtml
Normal file
2
src/Nalu.Api/Pages/Auth/Callback.cshtml
Normal file
@ -0,0 +1,2 @@
|
||||
@page "/auth/callback"
|
||||
@model Nalu.Web.Pages.Auth.CallbackModel
|
||||
76
src/Nalu.Api/Pages/Auth/Callback.cshtml.cs
Normal file
76
src/Nalu.Api/Pages/Auth/Callback.cshtml.cs
Normal 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("//");
|
||||
}
|
||||
168
src/Nalu.Api/Pages/Casos/Parcelas48x.cshtml
Normal file
168
src/Nalu.Api/Pages/Casos/Parcelas48x.cshtml
Normal 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>
|
||||
8
src/Nalu.Api/Pages/Casos/Parcelas48x.cshtml.cs
Normal file
8
src/Nalu.Api/Pages/Casos/Parcelas48x.cshtml.cs
Normal file
@ -0,0 +1,8 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace Nalu.Web.Pages.Casos;
|
||||
|
||||
public class Parcelas48xModel : PageModel
|
||||
{
|
||||
public void OnGet() { }
|
||||
}
|
||||
395
src/Nalu.Api/Pages/Index.cshtml
Normal file
395
src/Nalu.Api/Pages/Index.cshtml
Normal 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<ExtractionResponse>();
|
||||
// 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>
|
||||
}
|
||||
42
src/Nalu.Api/Pages/Index.cshtml.cs
Normal file
42
src/Nalu.Api/Pages/Index.cshtml.cs
Normal 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() { }
|
||||
}
|
||||
71
src/Nalu.Api/Pages/Login.cshtml
Normal file
71
src/Nalu.Api/Pages/Login.cshtml
Normal 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>
|
||||
32
src/Nalu.Api/Pages/Login.cshtml.cs
Normal file
32
src/Nalu.Api/Pages/Login.cshtml.cs
Normal 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");
|
||||
}
|
||||
160
src/Nalu.Api/Pages/Painel/Index.cshtml
Normal file
160
src/Nalu.Api/Pages/Painel/Index.cshtml
Normal 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>
|
||||
}
|
||||
67
src/Nalu.Api/Pages/Painel/Index.cshtml.cs
Normal file
67
src/Nalu.Api/Pages/Painel/Index.cshtml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
212
src/Nalu.Api/Pages/Precos.cshtml
Normal file
212
src/Nalu.Api/Pages/Precos.cshtml
Normal 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>
|
||||
8
src/Nalu.Api/Pages/Precos.cshtml.cs
Normal file
8
src/Nalu.Api/Pages/Precos.cshtml.cs
Normal file
@ -0,0 +1,8 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace Nalu.Web.Pages;
|
||||
|
||||
public class PrecosModel : PageModel
|
||||
{
|
||||
public void OnGet() { }
|
||||
}
|
||||
144
src/Nalu.Api/Pages/Validadores/Reply.cshtml
Normal file
144
src/Nalu.Api/Pages/Validadores/Reply.cshtml
Normal 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>
|
||||
63
src/Nalu.Api/Pages/Validadores/Reply.cshtml.cs
Normal file
63
src/Nalu.Api/Pages/Validadores/Reply.cshtml.cs
Normal 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() { }
|
||||
}
|
||||
116
src/Nalu.Api/Pages/_Layout.cshtml
Normal file
116
src/Nalu.Api/Pages/_Layout.cshtml
Normal 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>
|
||||
4
src/Nalu.Api/Pages/_ViewImports.cshtml
Normal file
4
src/Nalu.Api/Pages/_ViewImports.cshtml
Normal file
@ -0,0 +1,4 @@
|
||||
@using Nalu.Web.Pages
|
||||
@using System.Security.Claims
|
||||
@namespace Nalu.Web.Pages
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
3
src/Nalu.Api/Pages/_ViewStart.cshtml
Normal file
3
src/Nalu.Api/Pages/_ViewStart.cshtml
Normal file
@ -0,0 +1,3 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
43
src/Nalu.Api/PostProcessors/CalculateAge.cs
Normal file
43
src/Nalu.Api/PostProcessors/CalculateAge.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
33
src/Nalu.Api/PostProcessors/CapitalizeProperName.cs
Normal file
33
src/Nalu.Api/PostProcessors/CapitalizeProperName.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
57
src/Nalu.Api/PostProcessors/CorrectEmailTypos.cs
Normal file
57
src/Nalu.Api/PostProcessors/CorrectEmailTypos.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
23
src/Nalu.Api/PostProcessors/FormatCep.cs
Normal file
23
src/Nalu.Api/PostProcessors/FormatCep.cs
Normal 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..]}");
|
||||
}
|
||||
}
|
||||
69
src/Nalu.Api/PostProcessors/FormatPhone.cs
Normal file
69
src/Nalu.Api/PostProcessors/FormatPhone.cs
Normal 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);
|
||||
}
|
||||
39
src/Nalu.Api/PostProcessors/FormatPlate.cs
Normal file
39
src/Nalu.Api/PostProcessors/FormatPlate.cs
Normal 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).");
|
||||
}
|
||||
}
|
||||
26
src/Nalu.Api/PostProcessors/IPostProcessor.cs
Normal file
26
src/Nalu.Api/PostProcessors/IPostProcessor.cs
Normal 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);
|
||||
}
|
||||
31
src/Nalu.Api/PostProcessors/NormalizePostalCode.cs
Normal file
31
src/Nalu.Api/PostProcessors/NormalizePostalCode.cs
Normal file
@ -0,0 +1,31 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Nalu.Web.PostProcessors;
|
||||
|
||||
public class NormalizePostalCode : IPostProcessor
|
||||
{
|
||||
public string Name => "normalize_postal_code";
|
||||
|
||||
// Accepts: 3–10 alphanumeric chars, optionally split by a single space or hyphen
|
||||
private static readonly Regex ValidPattern =
|
||||
new(@"^[A-Z0-9]{2,}([\s\-][A-Z0-9]{2,})?$", RegexOptions.Compiled);
|
||||
|
||||
public ProcessorResult Process(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return ProcessorResult.Invalid("Código postal vazio");
|
||||
|
||||
// Uppercase + collapse whitespace
|
||||
var normalized = Regex.Replace(value.Trim().ToUpperInvariant(), @"\s+", " ");
|
||||
|
||||
// Reject all-zeros
|
||||
if (Regex.IsMatch(normalized, @"^0+$"))
|
||||
return ProcessorResult.Invalid("Código postal inválido (todos zeros)");
|
||||
|
||||
// Must contain at least one digit or letter group matching postal pattern
|
||||
if (!ValidPattern.IsMatch(normalized))
|
||||
return ProcessorResult.Invalid($"Formato de código postal não reconhecido: {normalized}");
|
||||
|
||||
return ProcessorResult.Ok(normalized);
|
||||
}
|
||||
}
|
||||
115
src/Nalu.Api/PostProcessors/ParseDate.cs
Normal file
115
src/Nalu.Api/PostProcessors/ParseDate.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
28
src/Nalu.Api/PostProcessors/RemoveTitles.cs
Normal file
28
src/Nalu.Api/PostProcessors/RemoveTitles.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
36
src/Nalu.Api/PostProcessors/SelectCancelSuggestion.cs
Normal file
36
src/Nalu.Api/PostProcessors/SelectCancelSuggestion.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/Nalu.Api/PostProcessors/SelectHandoffSuggestion.cs
Normal file
38
src/Nalu.Api/PostProcessors/SelectHandoffSuggestion.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
src/Nalu.Api/PostProcessors/ValidateCnpjDigit.cs
Normal file
49
src/Nalu.Api/PostProcessors/ValidateCnpjDigit.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
47
src/Nalu.Api/PostProcessors/ValidateCpfDigit.cs
Normal file
47
src/Nalu.Api/PostProcessors/ValidateCpfDigit.cs
Normal 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
266
src/Nalu.Api/Program.cs
Normal 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 { }
|
||||
25
src/Nalu.Api/Properties/launchSettings.json
Normal file
25
src/Nalu.Api/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/Nalu.Api/Services/AuthService.cs
Normal file
31
src/Nalu.Api/Services/AuthService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
38
src/Nalu.Api/Services/CacheService.cs
Normal file
38
src/Nalu.Api/Services/CacheService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
55
src/Nalu.Api/Services/CreditCosts.cs
Normal file
55
src/Nalu.Api/Services/CreditCosts.cs
Normal 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;
|
||||
}
|
||||
126
src/Nalu.Api/Services/CreditService.cs
Normal file
126
src/Nalu.Api/Services/CreditService.cs
Normal 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..];
|
||||
}
|
||||
125
src/Nalu.Api/Services/DeterministicLayer.cs
Normal file
125
src/Nalu.Api/Services/DeterministicLayer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
32
src/Nalu.Api/Services/EnrichmentService.cs
Normal file
32
src/Nalu.Api/Services/EnrichmentService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
152
src/Nalu.Api/Services/ExtractionPipeline.cs
Normal file
152
src/Nalu.Api/Services/ExtractionPipeline.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
156
src/Nalu.Api/Services/LlmExtractionService.cs
Normal file
156
src/Nalu.Api/Services/LlmExtractionService.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
74
src/Nalu.Api/Services/LlmRouter/GoogleAiProvider.cs
Normal file
74
src/Nalu.Api/Services/LlmRouter/GoogleAiProvider.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
73
src/Nalu.Api/Services/LlmRouter/GroqProvider.cs
Normal file
73
src/Nalu.Api/Services/LlmRouter/GroqProvider.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
7
src/Nalu.Api/Services/LlmRouter/ILlmProvider.cs
Normal file
7
src/Nalu.Api/Services/LlmRouter/ILlmProvider.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace Nalu.Web.Services.LlmRouter;
|
||||
|
||||
public interface ILlmProvider
|
||||
{
|
||||
string Name { get; }
|
||||
Task<LlmResponse> CompleteAsync(LlmRequest request, CancellationToken ct);
|
||||
}
|
||||
6
src/Nalu.Api/Services/LlmRouter/ILlmRouter.cs
Normal file
6
src/Nalu.Api/Services/LlmRouter/ILlmRouter.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace Nalu.Web.Services.LlmRouter;
|
||||
|
||||
public interface ILlmRouter
|
||||
{
|
||||
Task<LlmResponse> CompleteAsync(LlmRequest request, CancellationToken ct);
|
||||
}
|
||||
23
src/Nalu.Api/Services/LlmRouter/LlmModels.cs
Normal file
23
src/Nalu.Api/Services/LlmRouter/LlmModels.cs
Normal 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);
|
||||
29
src/Nalu.Api/Services/LlmRouter/LlmRouter.cs
Normal file
29
src/Nalu.Api/Services/LlmRouter/LlmRouter.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
71
src/Nalu.Api/Services/LlmRouter/OpenRouterProvider.cs
Normal file
71
src/Nalu.Api/Services/LlmRouter/OpenRouterProvider.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
61
src/Nalu.Api/Services/PostProcessorRegistry.cs
Normal file
61
src/Nalu.Api/Services/PostProcessorRegistry.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
80
src/Nalu.Api/Services/RateLimitService.cs
Normal file
80
src/Nalu.Api/Services/RateLimitService.cs
Normal 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);
|
||||
}
|
||||
160
src/Nalu.Api/Services/ReplyService.cs
Normal file
160
src/Nalu.Api/Services/ReplyService.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
119
src/Nalu.Api/Services/SuggestionBuilder.cs
Normal file
119
src/Nalu.Api/Services/SuggestionBuilder.cs
Normal 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 "";
|
||||
}
|
||||
}
|
||||
78
src/Nalu.Api/Services/UserService.cs
Normal file
78
src/Nalu.Api/Services/UserService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
294
src/Nalu.Api/Services/ValidatorLoader.cs
Normal file
294
src/Nalu.Api/Services/ValidatorLoader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
151
src/Nalu.Api/Validators/validate_birthdate.md
Normal file
151
src/Nalu.Api/Validators/validate_birthdate.md
Normal 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.
|
||||
132
src/Nalu.Api/Validators/validate_cancel_intent.md
Normal file
132
src/Nalu.Api/Validators/validate_cancel_intent.md
Normal 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)
|
||||
87
src/Nalu.Api/Validators/validate_cep.md
Normal file
87
src/Nalu.Api/Validators/validate_cep.md
Normal 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
|
||||
86
src/Nalu.Api/Validators/validate_cnpj.md
Normal file
86
src/Nalu.Api/Validators/validate_cnpj.md
Normal 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)
|
||||
111
src/Nalu.Api/Validators/validate_company_name.md
Normal file
111
src/Nalu.Api/Validators/validate_company_name.md
Normal 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?
|
||||
98
src/Nalu.Api/Validators/validate_cpf.md
Normal file
98
src/Nalu.Api/Validators/validate_cpf.md
Normal 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)
|
||||
101
src/Nalu.Api/Validators/validate_email.md
Normal file
101
src/Nalu.Api/Validators/validate_email.md
Normal 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)
|
||||
110
src/Nalu.Api/Validators/validate_full_name.md
Normal file
110
src/Nalu.Api/Validators/validate_full_name.md
Normal 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
Loading…
Reference in New Issue
Block a user