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