- 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>
233 lines
12 KiB
C#
233 lines
12 KiB
C#
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();
|
|
}
|
|
}
|