curl https://api.naluai.com/v1/extract/reply \
+ curl https://api.naluai.dev/v1/extract/reply \
-H "Authorization: Bearer SEU_TOKEN" \
-H "Content-Type: application/json" \
-d '{
@@ -142,7 +142,7 @@
JavaScript (n8n / Make)
const { reply_type, extracted_value, value_type } =
- await $http.post('https://api.naluai.com/v1/extract/reply', {
+ await $http.post('https://api.naluai.dev/v1/extract/reply', {
agent_message: $node['Agent'].json.message,
user_reply: $node['User'].json.reply,
language: 'pt-BR'
diff --git a/src/Nalu.Api/Pages/Index.cshtml b/src/Nalu.Api/Pages/Index.cshtml
index c6858da..5187e69 100644
--- a/src/Nalu.Api/Pages/Index.cshtml
+++ b/src/Nalu.Api/Pages/Index.cshtml
@@ -221,7 +221,7 @@ Mais barato que perder a venda.
curl https://api.naluai.com/v1/extract/name \ +curl https://api.naluai.dev/v1/extract/name \ -H "Authorization: Bearer SEU_TOKEN" \ -H "Content-Type: application/json" \ -d '{ @@ -241,7 +241,7 @@ Mais barato que perder a venda.diff --git a/src/Nalu.Api/Pages/Painel/Index.cshtml b/src/Nalu.Api/Pages/Painel/Index.cshtml index da4898f..a8048b6 100644 --- a/src/Nalu.Api/Pages/Painel/Index.cshtml +++ b/src/Nalu.Api/Pages/Painel/Index.cshtml @@ -140,7 +140,7 @@- +Como usar-curl https://api.naluai.com/v1/extract/cpf \ +curl https://api.naluai.dev/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"}'diff --git a/src/Nalu.Api/PostProcessors/ValidateCnpjDigit.cs b/src/Nalu.Api/PostProcessors/ValidateCnpjDigit.cs index ca1c473..27325ef 100644 --- a/src/Nalu.Api/PostProcessors/ValidateCnpjDigit.cs +++ b/src/Nalu.Api/PostProcessors/ValidateCnpjDigit.cs @@ -2,8 +2,10 @@ 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. +/// Validates CNPJ check digits (mod 11 algorithm) for both numeric and alphanumeric CNPJs +/// (IN RFB 2229/2024). Digits use face value (0–9); letters use raw ASCII (A=65…Z=90). +/// Last two characters must always be numeric check digits. +/// Formats as XX.XXX.XXX/XXXX-XX. public class ValidateCnpjDigit : IPostProcessor { public string Name => "validate_cnpj_digit"; @@ -13,37 +15,51 @@ public class ValidateCnpjDigit : IPostProcessor if (string.IsNullOrWhiteSpace(value)) return ProcessorResult.Invalid("CNPJ não informado"); - var digits = Regex.Replace(value, @"\D", ""); + // Uppercase and strip separators only (keep alphanumeric) + var normalized = Regex.Replace(value.ToUpperInvariant(), @"[.\-/\s]", ""); - if (digits.Length != 14) - return ProcessorResult.Invalid($"CNPJ deve ter 14 dígitos (encontrado: {digits.Length})"); + if (normalized.Length != 14) + return ProcessorResult.Invalid($"CNPJ deve ter 14 caracteres (encontrado: {normalized.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)"); + // Validate character set: only digits and uppercase A-Z + if (!normalized.All(c => char.IsDigit(c) || (c >= 'A' && c <= 'Z'))) + return ProcessorResult.Invalid("CNPJ contém caracteres inválidos"); - if (!CheckDigits(digits)) + // Last two characters must be digits (check digits are always numeric) + if (!normalized[12..].All(char.IsDigit)) + return ProcessorResult.Invalid("Os dois últimos caracteres do CNPJ devem ser dígitos"); + + // Reject all-same-character sequences (000…0, AAA…A, etc.) + if (normalized.Distinct().Count() == 1) + return ProcessorResult.Invalid("CNPJ inválido (sequência de caracteres repetidos)"); + + if (!CheckDigits(normalized)) 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..]}"; + var formatted = $"{normalized[..2]}.{normalized[2..5]}.{normalized[5..8]}/{normalized[8..12]}-{normalized[12..]}"; return ProcessorResult.Ok(formatted); } - private static bool CheckDigits(string d) + /// Maps a character to its numeric value for the mod 11 calculation. + /// Digits: face value (0–9). Letters: raw ASCII value (A=65 … Z=90). + /// Per Receita Federal CNPJ alphanumeric spec (COCAD). + private static int CharValue(char c) => char.IsDigit(c) ? c - '0' : (int)c; + + private static bool CheckDigits(string s) { - // First check digit (position 12) + // First check digit (position 12, 0-indexed) 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 sum1 = w1.Select((w, i) => CharValue(s[i]) * w).Sum(); var r1 = sum1 % 11; var cd1 = r1 < 2 ? 0 : 11 - r1; - if (d[12] - '0' != cd1) return false; + if (s[12] - '0' != cd1) return false; - // Second check digit (position 13) + // Second check digit (position 13, 0-indexed) 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 sum2 = w2.Select((w, i) => CharValue(s[i]) * w).Sum(); var r2 = sum2 % 11; var cd2 = r2 < 2 ? 0 : 11 - r2; - return d[13] - '0' == cd2; + return s[13] - '0' == cd2; } } diff --git a/src/Nalu.Api/Program.cs b/src/Nalu.Api/Program.cs index 56fa502..3f2db39 100644 --- a/src/Nalu.Api/Program.cs +++ b/src/Nalu.Api/Program.cs @@ -150,7 +150,7 @@ builder.Services.AddHttpClient(client => var baseUrl = builder.Configuration["OpenRouter:BaseUrl"] ?? "https://openrouter.ai/api/v1"; client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + '/'); client.DefaultRequestHeaders.Add("Authorization", $"Bearer {builder.Configuration["OpenRouter:ApiKey"]}"); - client.DefaultRequestHeaders.Add("HTTP-Referer", "https://naluai.com"); + client.DefaultRequestHeaders.Add("HTTP-Referer", "https://naluai.dev"); client.DefaultRequestHeaders.Add("X-Title", "NALU AI"); client.Timeout = TimeSpan.FromSeconds(30); }); diff --git a/src/Nalu.Api/Services/CreditCosts.cs b/src/Nalu.Api/Services/CreditCosts.cs index a7328d5..74b066a 100644 --- a/src/Nalu.Api/Services/CreditCosts.cs +++ b/src/Nalu.Api/Services/CreditCosts.cs @@ -17,6 +17,7 @@ public static class CreditCosts ["validate_postal_code"]= 1, // 2 credits — light LLM + ["validate_name"] = 2, ["validate_full_name"] = 2, ["validate_yes_no"] = 2, ["validate_birthdate"] = 2, @@ -34,7 +35,8 @@ public static class CreditCosts // Endpoint aliases → validator IDs private static readonly Dictionary _aliases = new(StringComparer.OrdinalIgnoreCase) { - ["name"] = "validate_full_name", + ["name"] = "validate_name", + ["full-name"] = "validate_full_name", ["cpf"] = "validate_cpf", ["cep"] = "validate_cep", ["phone"] = "validate_phone_br", diff --git a/src/Nalu.Api/Services/CreditService.cs b/src/Nalu.Api/Services/CreditService.cs index 7500896..80ea253 100644 --- a/src/Nalu.Api/Services/CreditService.cs +++ b/src/Nalu.Api/Services/CreditService.cs @@ -117,7 +117,7 @@ public class CreditService(MongoDbContext db, IConfiguration config) credits_used = used, credits_limit = limit, reset_at = resetAt.ToString("O"), - upgrade_url = "https://naluai.com/precos", + upgrade_url = "https://naluai.dev/precos", hint = "Upgrade para Starter por apenas R$ 0,0019 por validação. Menos que uma gota de café." }; diff --git a/src/Nalu.Api/Services/DeterministicLayer.cs b/src/Nalu.Api/Services/DeterministicLayer.cs index 7d4cdc6..ae5b9e2 100644 --- a/src/Nalu.Api/Services/DeterministicLayer.cs +++ b/src/Nalu.Api/Services/DeterministicLayer.cs @@ -55,17 +55,21 @@ public class DeterministicLayer catch (RegexMatchTimeoutException) { /* skip on timeout */ } } - // Accept patterns — capture group 1 is the extracted value + // Accept patterns — capture group 1 is the extracted value. + // Matched against the ORIGINAL (trimmed) input without global IgnoreCase, + // so patterns can be case-sensitive. Use (?i) inline for case-insensitive patterns. + var original = userInput.Trim().TrimEnd('.', '!', '?', ',', ';'); + foreach (var pattern in validator.AcceptPatterns) { try { - var m = Regex.Match(normalized, pattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100)); + var m = Regex.Match(original, pattern, RegexOptions.None, TimeSpan.FromMilliseconds(100)); if (!m.Success) continue; var extracted = m.Groups.Count > 1 && m.Groups[1].Success ? m.Groups[1].Value.Trim() - : userInput.Trim(); + : original; var violation = CheckConstraints(validator.Constraints, extracted); if (violation is not null) @@ -120,6 +124,14 @@ public class DeterministicLayer return "Valor deve conter letras"; } + if (constraints.TryGetValue("must_have_space", out var spaceStr) + && bool.TryParse(spaceStr, out var mustHaveSpace) + && mustHaveSpace) + { + if (!value.Contains(' ')) + return "Valor deve conter ao menos duas palavras"; + } + return null; } } diff --git a/src/Nalu.Api/Validators/validate_cnpj.md b/src/Nalu.Api/Validators/validate_cnpj.md index fcf754c..8595cf1 100644 --- a/src/Nalu.Api/Validators/validate_cnpj.md +++ b/src/Nalu.Api/Validators/validate_cnpj.md @@ -1,15 +1,15 @@ # validate_cnpj -Extrai e valida CNPJ brasileiro (14 dígitos com algoritmo mod 11). +Extrai e valida CNPJ brasileiro — numérico (formato atual) e alfanumérico (novo formato IN RFB 2229/2024). ## config - type: extraction -- version: 1.0 +- version: 1.1 - 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. +- mcp_description: Extrai e valida CNPJ brasileiro — suporta formato numérico clássico e o novo formato alfanumérico (IN RFB 2229/2024). Valida dígitos verificadores com mod 11. Formata como XX.XXX.XXX/XXXX-XX. Os dois últimos caracteres são sempre dígitos numéricos. Se obtained=false, use suggestion_to_agent para pedir novamente. ## deterministic_rules @@ -18,23 +18,27 @@ bom dia, boa tarde, boa noite, olá, oi, não tenho, nao tenho, não sei, sem cn ### reject_patterns - ^[a-zA-Z\s]+$ -- ^(\d)\1{13}$ +- ^(.)\1{13}$ ### accept_patterns -- (\d{2}[\.\s]?\d{3}[\.\s]?\d{3}[\/\s]?\d{4}[-\s]?\d{2}) -- (\d{14}) +- ([A-Z0-9]{2}\.?[A-Z0-9]{3}\.?[A-Z0-9]{3}\/?[A-Z0-9]{4}-?\d{2}) +- ([A-Z0-9]{12}\d{2}) ### constraints - min_length: 14 +- value_pattern: ^[A-Z0-9.\-/\s]{14,18}$ ## prompt Você é um extrator de CNPJ. Dado o diálogo abaixo, extraia o CNPJ que a empresa informou. +O CNPJ pode ser numérico (ex: 11.222.333/0001-81) ou alfanumérico no novo formato (ex: AB.CDE.FGH/0001-23), com 14 caracteres no total. Os dois últimos caracteres são sempre dígitos numéricos. + 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. +1. Extraia os 14 caracteres do CNPJ (letras maiúsculas + dígitos), sem pontuação ou separadores. +2. Se o CNPJ tiver letras, mantenha-as em maiúsculas. +3. Se o usuário não forneceu CNPJ ou foi evasivo, retorne extracted_value: null. +4. Não valide o CNPJ — apenas extraia os caracteres. Diálogo: Agente: {{agent_input}} @@ -44,7 +48,7 @@ Contexto do agente: {{agent_context}} Responda SOMENTE com JSON válido, sem markdown: { - "extracted_value": "14 dígitos do CNPJ ou null", + "extracted_value": "14 caracteres do CNPJ sem formatação ou null", "certain": true/false, "reasoning": "explicação curta" } @@ -61,6 +65,11 @@ Responda SOMENTE com JSON válido, sem markdown: - user_input: é 11222333000181 - output: {"extracted_value": "11222333000181", "certain": true, "reasoning": "CNPJ sem formatação"} +### example 5 +- agent_input: Qual o CNPJ da empresa? +- user_input: AB.CDE.FGH/0001-23 +- output: {"extracted_value": "ABCDEFGH000123", "certain": true, "reasoning": "CNPJ alfanumérico novo formato"} + ### example 3 - agent_input: Qual o CNPJ da empresa? - user_input: não tenho aqui agora @@ -77,7 +86,7 @@ Responda SOMENTE com JSON válido, sem markdown: ## suggestions ### when_null_evasive -Preciso do CNPJ da empresa para continuar. Pode informar? São 14 dígitos (formato: XX.XXX.XXX/XXXX-XX) +Preciso do CNPJ da empresa para continuar. Pode informar? (formato: XX.XXX.XXX/XXXX-XX — numérico ou alfanumérico) ### when_invalid Esse CNPJ parece estar incorreto. Pode verificar? São 14 dígitos (XX.XXX.XXX/XXXX-XX). diff --git a/src/Nalu.Api/Validators/validate_cpf.md b/src/Nalu.Api/Validators/validate_cpf.md index c803b5b..11f19bd 100644 --- a/src/Nalu.Api/Validators/validate_cpf.md +++ b/src/Nalu.Api/Validators/validate_cpf.md @@ -25,6 +25,7 @@ bom dia, boa tarde, boa noite, olá, oi ### constraints - min_length: 11 +- value_pattern: ^[\d.\-\s]{11,14}$ ## prompt diff --git a/src/Nalu.Api/Validators/validate_full_name.md b/src/Nalu.Api/Validators/validate_full_name.md index 83c90db..620f860 100644 --- a/src/Nalu.Api/Validators/validate_full_name.md +++ b/src/Nalu.Api/Validators/validate_full_name.md @@ -1,15 +1,15 @@ # validate_full_name -Extrai o nome completo do usuário a partir do diálogo. +Extrai o nome completo do usuário (com sobrenome) 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. +- endpoint: /v1/extract/full-name +- mcp_tool: nalu_extract_full_name +- mcp_description: Extrai o nome completo do usuário (nome + sobrenome). Use quando o agente precisa do nome completo para cadastro, contrato ou triagem. Retorna o nome extraído, nível de certeza e sugestão de fala. 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 @@ -21,27 +21,30 @@ bom dia, boa tarde, boa noite, olá, oi, tudo bem, e aí, fala, eae, opa - ^\d+$ ### accept_patterns -- ^meu nome é\s+(.+)$ -- ^me chamo\s+(.+)$ -- ^sou o\s+(.+)$ -- ^sou a\s+(.+)$ -- ^pode me chamar de\s+(.+)$ +- (?i)^me chamo\s+(.+)$ +- (?i)^meu nome completo é\s+(.+)$ +- (?i)^my name is\s+(.+)$ +- (?i)^me llamo\s+(.+)$ +- ^([A-ZÁÉÍÓÚÀÂÊÔÃÕÇÜÑ][a-záéíóúàâêôãõçüñ'ã-]+(?:\s+(?:de|da|do|dos|das|e|[A-ZÁÉÍÓÚÀÂÊÔÃÕÇÜÑ][a-záéíóúàâêôãõçüñ'ã-]+)){1,})$ ### constraints - min_length: 2 - must_have_alpha: true +- must_have_space: 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. +Você é um extrator de nomes completos. Dado o diálogo abaixo entre um agente e um usuário, extraia o nome completo (com sobrenome) 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). +1. Se o usuário respondeu apenas com saudação (bom dia, oi, etc.) sem dizer o nome, retorne extracted_value: null. +2. Nome completo requer ao menos um sobrenome (duas palavras ou mais). Se só informou primeiro nome, retorne com o nome mas certain: false. +3. Extraia o nome completo e avalie: + - certain: true → nome + sobrenome claramente humanos em PT, EN ou ES (ex: "Maria Silva", "Carlos Eduardo Santos", "Michael Johnson", "Paulo da Silva"). + - certain: false → qualquer parte não é claramente humana: substantivo comum (Segredo Silva, Piano Souza), marca (Xerox Santos), instrumento/objeto (Xilofone Ninguém), palavra ofensiva/zueira, ou frase ambígua entre nome e recusa. +4. Se o usuário confirmou o nome após o agente perguntar, retorne certain: true mesmo se incomum. +5. Normalize: primeira letra maiúscula em cada palavra, exceto preposições (de/da/do/dos/das/e). Diálogo: Agente: {{agent_input}} @@ -64,29 +67,49 @@ Responda SOMENTE com JSON válido, sem markdown, sem explicação: - 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"} +- agent_input: Qual seu nome completo? +- user_input: Carlos +- output: {"extracted_value": "Carlos", "certain": false, "reasoning": "Apenas primeiro nome, falta o sobrenome"} ### 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"} +- output: {"extracted_value": "Maria Silva dos Santos", "certain": true, "reasoning": "Nome completo humano reconhecido"} ### example 4 +- agent_input: Qual seu nome completo? +- user_input: Ricardo Carneiro +- output: {"extracted_value": "Ricardo Carneiro", "certain": true, "reasoning": "Nome completo humano reconhecido em PT"} + +### example 5 - 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"} +- agent_input: Qual seu nome completo? +- user_input: Meu nome é segredo. +- output: {"extracted_value": "Segredo", "certain": false, "reasoning": "Ambíguo: recusa ou nome? Segredo é substantivo comum"} + +### example 7 +- agent_input: Qual seu nome completo? +- user_input: Segredo da Silva +- output: {"extracted_value": "Segredo da Silva", "certain": false, "reasoning": "Segredo é substantivo comum, não antropônimo"} + +### example 8 +- agent_input: What's your full name? +- user_input: Piano Smith +- output: {"extracted_value": "Piano Smith", "certain": false, "reasoning": "Piano is an instrument, not a human first name"} + +### example 9 +- agent_input: Qual seu nome completo? +- user_input: Carlos Eduardo Santos +- output: {"extracted_value": "Carlos Eduardo Santos", "certain": true, "reasoning": "Nome completo humano reconhecido"} + +### example 10 +- agent_input: Seu nome completo é Segredo da Silva? +- user_input: Sim, pode registrar assim. +- output: {"extracted_value": "Segredo da Silva", "certain": true, "reasoning": "Usuário confirmou o nome após questionamento"} ## post_processors - capitalize_proper_name @@ -104,7 +127,7 @@ Responda SOMENTE com JSON válido, sem markdown, sem explicação: Sem problemas, mas preciso do seu nome para prosseguir. Qual seu nome completo? ### when_uncertain -Só confirmando: seu nome é {{extracted_value}}? Pode confirmar? +Seu nome é {{extracted_value}}? Pode confirmar que posso registrá-lo assim? ### when_certain (sem sugestão — agente segue o fluxo) diff --git a/src/Nalu.Api/Validators/validate_yes_no.md b/src/Nalu.Api/Validators/validate_yes_no.md index d80d954..c42f596 100644 --- a/src/Nalu.Api/Validators/validate_yes_no.md +++ b/src/Nalu.Api/Validators/validate_yes_no.md @@ -23,6 +23,7 @@ bom dia, boa tarde, boa noite, olá, oi ### accept_patterns ### constraints +- value_pattern: ^(true|false)$ ## prompt diff --git a/src/Nalu.Jobs/CuratedNamesImporter.cs b/src/Nalu.Jobs/CuratedNamesImporter.cs new file mode 100644 index 0000000..3a1f0ef --- /dev/null +++ b/src/Nalu.Jobs/CuratedNamesImporter.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Nalu.Jobs; + +/// +/// Imports the curated Brazilian names list (Data/names_curated.json) into MongoDB. +/// Complements the IBGE importer with names not covered by the IBGE top-20 ranking. +/// Uses the same nomes_br collection and upsert logic. +/// +public static class CuratedNamesImporter +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true + }; + + private static readonly string[] EmbeddedFiles = + [ + "names_curated.json", + "names_curated_en.json", + "names_curated_es.json" + ]; + + public static async Task ImportAsync( + IMongoDatabase db, + ILogger logger, + CancellationToken ct) + { + var repo = new NameRepository(db); + await repo.EnsureIndexesAsync(ct); + + var now = DateTime.UtcNow; + int totalInserted = 0, totalUpdated = 0; + + foreach (var fileName in EmbeddedFiles) + { + var entries = LoadEmbeddedList(fileName); + logger.LogInformation("Curated list '{File}' loaded — {Count} entries", fileName, entries.Length); + + foreach (var entry in entries) + { + ct.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(entry.Nome)) continue; + + var aggregated = new AggregatedName( + Nome: entry.Nome.Trim().ToUpperInvariant(), + Frequencia: entry.Frequencia, + Genero: NormalizeGenero(entry.Genero)); + + var wasInserted = await repo.UpsertAsync(aggregated, now, ct); + if (wasInserted) totalInserted++; else totalUpdated++; + } + } + + logger.LogInformation( + "Curated import done — inserted={I} updated={U}", totalInserted, totalUpdated); + } + + private static CuratedEntry[] LoadEmbeddedList(string fileName) + { + var asm = typeof(CuratedNamesImporter).Assembly; + var resourceName = asm.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith(fileName, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"Embedded resource '{fileName}' not found."); + + using var stream = asm.GetManifestResourceStream(resourceName)!; + return JsonSerializer.Deserialize(stream, JsonOpts) + ?? throw new InvalidDataException($"'{fileName}' is empty or invalid."); + } + + private static string NormalizeGenero(string? g) => g?.ToUpperInvariant() switch + { + "M" => "M", + "F" => "F", + _ => "N" + }; + + private record CuratedEntry + { + [JsonPropertyName("nome")] public string Nome { get; init; } = ""; + [JsonPropertyName("genero")] public string? Genero { get; init; } + [JsonPropertyName("frequencia")] public long Frequencia { get; init; } + } +} diff --git a/src/Nalu.Jobs/Data/names_curated.json b/src/Nalu.Jobs/Data/names_curated.json new file mode 100644 index 0000000..8c878b6 --- /dev/null +++ b/src/Nalu.Jobs/Data/names_curated.json @@ -0,0 +1,379 @@ +[ + { "nome": "JOSE", "genero": "M", "frequencia": 5732508 }, + { "nome": "JOAO", "genero": "M", "frequencia": 2971935 }, + { "nome": "ANTONIO", "genero": "M", "frequencia": 2567494 }, + { "nome": "FRANCISCO", "genero": "M", "frequencia": 1765197 }, + { "nome": "CARLOS", "genero": "M", "frequencia": 1483121 }, + { "nome": "PAULO", "genero": "M", "frequencia": 1417907 }, + { "nome": "PEDRO", "genero": "M", "frequencia": 1213557 }, + { "nome": "LUCAS", "genero": "M", "frequencia": 1116818 }, + { "nome": "LUIZ", "genero": "M", "frequencia": 1102927 }, + { "nome": "MARCOS", "genero": "M", "frequencia": 1101126 }, + { "nome": "LUIS", "genero": "M", "frequencia": 931530 }, + { "nome": "GABRIEL", "genero": "M", "frequencia": 922744 }, + { "nome": "RAFAEL", "genero": "M", "frequencia": 814709 }, + { "nome": "DANIEL", "genero": "M", "frequencia": 706527 }, + { "nome": "MARCELO", "genero": "M", "frequencia": 690098 }, + { "nome": "BRUNO", "genero": "M", "frequencia": 663271 }, + { "nome": "EDUARDO", "genero": "M", "frequencia": 628539 }, + { "nome": "FELIPE", "genero": "M", "frequencia": 615924 }, + { "nome": "RAIMUNDO", "genero": "M", "frequencia": 611174 }, + { "nome": "RODRIGO", "genero": "M", "frequencia": 598825 }, + { "nome": "JORGE", "genero": "M", "frequencia": 560000 }, + { "nome": "MANOEL", "genero": "M", "frequencia": 540000 }, + { "nome": "ROBERTO", "genero": "M", "frequencia": 520000 }, + { "nome": "SERGIO", "genero": "M", "frequencia": 500000 }, + { "nome": "MARIO", "genero": "M", "frequencia": 490000 }, + { "nome": "ALEXANDRE", "genero": "M", "frequencia": 480000 }, + { "nome": "ANDRE", "genero": "M", "frequencia": 470000 }, + { "nome": "RICARDO", "genero": "M", "frequencia": 460000 }, + { "nome": "LEANDRO", "genero": "M", "frequencia": 450000 }, + { "nome": "DIEGO", "genero": "M", "frequencia": 440000 }, + { "nome": "LEONARDO", "genero": "M", "frequencia": 430000 }, + { "nome": "FERNANDO", "genero": "M", "frequencia": 420000 }, + { "nome": "GUSTAVO", "genero": "M", "frequencia": 415000 }, + { "nome": "THIAGO", "genero": "M", "frequencia": 410000 }, + { "nome": "VINICIUS", "genero": "M", "frequencia": 400000 }, + { "nome": "MATHEUS", "genero": "M", "frequencia": 395000 }, + { "nome": "HENRIQUE", "genero": "M", "frequencia": 390000 }, + { "nome": "GUILHERME", "genero": "M", "frequencia": 385000 }, + { "nome": "SAMUEL", "genero": "M", "frequencia": 380000 }, + { "nome": "ARTHUR", "genero": "M", "frequencia": 375000 }, + { "nome": "MIGUEL", "genero": "M", "frequencia": 370000 }, + { "nome": "DAVI", "genero": "M", "frequencia": 365000 }, + { "nome": "NICOLAS", "genero": "M", "frequencia": 360000 }, + { "nome": "FABIO", "genero": "M", "frequencia": 355000 }, + { "nome": "MARCIO", "genero": "M", "frequencia": 350000 }, + { "nome": "WAGNER", "genero": "M", "frequencia": 340000 }, + { "nome": "ROGERIO", "genero": "M", "frequencia": 330000 }, + { "nome": "RENATO", "genero": "M", "frequencia": 320000 }, + { "nome": "ADRIANO", "genero": "M", "frequencia": 315000 }, + { "nome": "CAIO", "genero": "M", "frequencia": 310000 }, + { "nome": "VITOR", "genero": "M", "frequencia": 305000 }, + { "nome": "MURILO", "genero": "M", "frequencia": 300000 }, + { "nome": "IGOR", "genero": "M", "frequencia": 295000 }, + { "nome": "DOUGLAS", "genero": "M", "frequencia": 290000 }, + { "nome": "ANDERSON", "genero": "M", "frequencia": 285000 }, + { "nome": "ALAN", "genero": "M", "frequencia": 280000 }, + { "nome": "CLAUDIO", "genero": "M", "frequencia": 275000 }, + { "nome": "EDSON", "genero": "M", "frequencia": 270000 }, + { "nome": "ELIAS", "genero": "M", "frequencia": 265000 }, + { "nome": "EMERSON", "genero": "M", "frequencia": 260000 }, + { "nome": "EVERTON", "genero": "M", "frequencia": 255000 }, + { "nome": "FLAVIO", "genero": "M", "frequencia": 250000 }, + { "nome": "GILBERTO", "genero": "M", "frequencia": 245000 }, + { "nome": "HELIO", "genero": "M", "frequencia": 240000 }, + { "nome": "IVAN", "genero": "M", "frequencia": 235000 }, + { "nome": "JEFFERSON", "genero": "M", "frequencia": 230000 }, + { "nome": "JULIO", "genero": "M", "frequencia": 225000 }, + { "nome": "LUAN", "genero": "M", "frequencia": 220000 }, + { "nome": "MICHEL", "genero": "M", "frequencia": 215000 }, + { "nome": "NATHAN", "genero": "M", "frequencia": 210000 }, + { "nome": "NILTON", "genero": "M", "frequencia": 205000 }, + { "nome": "OSVALDO", "genero": "M", "frequencia": 200000 }, + { "nome": "OTAVIO", "genero": "M", "frequencia": 195000 }, + { "nome": "RENAN", "genero": "M", "frequencia": 190000 }, + { "nome": "RONALDO", "genero": "M", "frequencia": 185000 }, + { "nome": "RUBENS", "genero": "M", "frequencia": 180000 }, + { "nome": "SANDRO", "genero": "M", "frequencia": 175000 }, + { "nome": "TIAGO", "genero": "M", "frequencia": 170000 }, + { "nome": "VALDIR", "genero": "M", "frequencia": 165000 }, + { "nome": "VICENTE", "genero": "M", "frequencia": 160000 }, + { "nome": "WELLINGTON", "genero": "M", "frequencia": 155000 }, + { "nome": "WESLEY", "genero": "M", "frequencia": 150000 }, + { "nome": "WILLIAM", "genero": "M", "frequencia": 145000 }, + { "nome": "YURI", "genero": "M", "frequencia": 140000 }, + { "nome": "EDER", "genero": "M", "frequencia": 135000 }, + { "nome": "EZEQUIEL", "genero": "M", "frequencia": 130000 }, + { "nome": "JAIR", "genero": "M", "frequencia": 125000 }, + { "nome": "KLEBER", "genero": "M", "frequencia": 120000 }, + { "nome": "MATEUS", "genero": "M", "frequencia": 115000 }, + { "nome": "NELSON", "genero": "M", "frequencia": 110000 }, + { "nome": "NILSON", "genero": "M", "frequencia": 105000 }, + { "nome": "ORLANDO", "genero": "M", "frequencia": 100000 }, + { "nome": "OSCAR", "genero": "M", "frequencia": 95000 }, + { "nome": "REGINALDO", "genero": "M", "frequencia": 90000 }, + { "nome": "SEBASTIAO", "genero": "M", "frequencia": 85000 }, + { "nome": "SILVIO", "genero": "M", "frequencia": 80000 }, + { "nome": "VALDECIR", "genero": "M", "frequencia": 75000 }, + { "nome": "VALTER", "genero": "M", "frequencia": 70000 }, + { "nome": "WANDERLEY", "genero": "M", "frequencia": 65000 }, + { "nome": "YAGO", "genero": "M", "frequencia": 60000 }, + { "nome": "BENEDITO", "genero": "M", "frequencia": 58000 }, + { "nome": "GERALDO", "genero": "M", "frequencia": 57000 }, + { "nome": "ARNALDO", "genero": "M", "frequencia": 56000 }, + { "nome": "ALFREDO", "genero": "M", "frequencia": 55000 }, + { "nome": "ARMANDO", "genero": "M", "frequencia": 54000 }, + { "nome": "ALMIR", "genero": "M", "frequencia": 53000 }, + { "nome": "ALTAIR", "genero": "M", "frequencia": 52000 }, + { "nome": "ALVARO", "genero": "M", "frequencia": 51000 }, + { "nome": "ARTUR", "genero": "M", "frequencia": 50000 }, + { "nome": "AURELIANO", "genero": "M", "frequencia": 49000 }, + { "nome": "AURELIO", "genero": "M", "frequencia": 48000 }, + { "nome": "BENTO", "genero": "M", "frequencia": 47000 }, + { "nome": "CELSO", "genero": "M", "frequencia": 46000 }, + { "nome": "CELIO", "genero": "M", "frequencia": 45000 }, + { "nome": "DALTON", "genero": "M", "frequencia": 44000 }, + { "nome": "DAMIAO", "genero": "M", "frequencia": 43000 }, + { "nome": "DANTE", "genero": "M", "frequencia": 42000 }, + { "nome": "DARIO", "genero": "M", "frequencia": 41000 }, + { "nome": "DENILSON", "genero": "M", "frequencia": 40000 }, + { "nome": "EDVALDO", "genero": "M", "frequencia": 39000 }, + { "nome": "ELTON", "genero": "M", "frequencia": 38000 }, + { "nome": "EUCLIDES", "genero": "M", "frequencia": 37000 }, + { "nome": "EURICO", "genero": "M", "frequencia": 36000 }, + { "nome": "EVALDO", "genero": "M", "frequencia": 35000 }, + { "nome": "FABRICIO", "genero": "M", "frequencia": 34000 }, + { "nome": "FIRMINO", "genero": "M", "frequencia": 33000 }, + { "nome": "GASPAR", "genero": "M", "frequencia": 32000 }, + { "nome": "GENIVALDO", "genero": "M", "frequencia": 31000 }, + { "nome": "HEITOR", "genero": "M", "frequencia": 30000 }, + { "nome": "HUMBERTO", "genero": "M", "frequencia": 29000 }, + { "nome": "IRINEU", "genero": "M", "frequencia": 28000 }, + { "nome": "ISAIAS", "genero": "M", "frequencia": 27000 }, + { "nome": "ITAMAR", "genero": "M", "frequencia": 26000 }, + { "nome": "JAIME", "genero": "M", "frequencia": 25000 }, + { "nome": "JARBAS", "genero": "M", "frequencia": 24000 }, + { "nome": "JEREMIAS", "genero": "M", "frequencia": 23000 }, + { "nome": "JOEL", "genero": "M", "frequencia": 22000 }, + { "nome": "JONATHAN", "genero": "M", "frequencia": 21500 }, + { "nome": "JOSUE", "genero": "M", "frequencia": 21000 }, + { "nome": "LAERCIO", "genero": "M", "frequencia": 20500 }, + { "nome": "LAURO", "genero": "M", "frequencia": 20000 }, + { "nome": "LINDOMAR", "genero": "M", "frequencia": 19500 }, + { "nome": "LUCIO", "genero": "M", "frequencia": 19000 }, + { "nome": "MESSIAS", "genero": "M", "frequencia": 18500 }, + { "nome": "MOACIR", "genero": "M", "frequencia": 18000 }, + { "nome": "MOISES", "genero": "M", "frequencia": 17500 }, + { "nome": "NAPOLEAO", "genero": "M", "frequencia": 17000 }, + { "nome": "NILDO", "genero": "M", "frequencia": 16500 }, + { "nome": "NOEL", "genero": "M", "frequencia": 16000 }, + { "nome": "NORBERTO", "genero": "M", "frequencia": 15500 }, + { "nome": "OLAVO", "genero": "M", "frequencia": 15000 }, + { "nome": "OLIMPIO", "genero": "M", "frequencia": 14500 }, + { "nome": "ONILDO", "genero": "M", "frequencia": 14000 }, + { "nome": "ORESTES", "genero": "M", "frequencia": 13500 }, + { "nome": "OTONIEL", "genero": "M", "frequencia": 13000 }, + { "nome": "PATRICIO", "genero": "M", "frequencia": 12500 }, + { "nome": "PERCIVAL", "genero": "M", "frequencia": 12000 }, + { "nome": "PLINIO", "genero": "M", "frequencia": 11500 }, + { "nome": "QUIRINO", "genero": "M", "frequencia": 11000 }, + { "nome": "RAMIRO", "genero": "M", "frequencia": 10500 }, + { "nome": "RANGEL", "genero": "M", "frequencia": 10000 }, + { "nome": "RINALDO", "genero": "M", "frequencia": 9500 }, + { "nome": "ROMARIO", "genero": "M", "frequencia": 9000 }, + { "nome": "ROMULO", "genero": "M", "frequencia": 8500 }, + { "nome": "RUBEM", "genero": "M", "frequencia": 8000 }, + { "nome": "SABINO", "genero": "M", "frequencia": 7500 }, + { "nome": "SALOMAO", "genero": "M", "frequencia": 7000 }, + { "nome": "SAULO", "genero": "M", "frequencia": 6500 }, + { "nome": "SIDNEI", "genero": "M", "frequencia": 6000 }, + { "nome": "TANCREDO", "genero": "M", "frequencia": 5500 }, + { "nome": "TARCISIO", "genero": "M", "frequencia": 5000 }, + { "nome": "TEODORO", "genero": "M", "frequencia": 4800 }, + { "nome": "TIMOTEO", "genero": "M", "frequencia": 4600 }, + { "nome": "TOBIAS", "genero": "M", "frequencia": 4400 }, + { "nome": "TULIO", "genero": "M", "frequencia": 4200 }, + { "nome": "ULISSES", "genero": "M", "frequencia": 4000 }, + { "nome": "VALENTIM", "genero": "M", "frequencia": 3800 }, + { "nome": "VANDERLEI", "genero": "M", "frequencia": 3600 }, + { "nome": "VENANCIO", "genero": "M", "frequencia": 3400 }, + { "nome": "ZACARIAS", "genero": "M", "frequencia": 3200 }, + { "nome": "ZAQUEU", "genero": "M", "frequencia": 3000 }, + { "nome": "ADRIEL", "genero": "M", "frequencia": 2800 }, + { "nome": "CAUÃ", "genero": "M", "frequencia": 2600 }, + { "nome": "ENZO", "genero": "M", "frequencia": 2400 }, + { "nome": "GIOVANNI", "genero": "M", "frequencia": 2200 }, + { "nome": "IAN", "genero": "M", "frequencia": 2000 }, + { "nome": "KAUÃ", "genero": "M", "frequencia": 1900 }, + { "nome": "KEVYN", "genero": "M", "frequencia": 1800 }, + { "nome": "NATAN", "genero": "M", "frequencia": 1700 }, + { "nome": "RYAN", "genero": "M", "frequencia": 1600 }, + { "nome": "THEO", "genero": "M", "frequencia": 1500 }, + { "nome": "BENICIO", "genero": "M", "frequencia": 1400 }, + { "nome": "HEITOR", "genero": "M", "frequencia": 30000 }, + { "nome": "ENZO", "genero": "M", "frequencia": 2400 }, + + { "nome": "MARIA", "genero": "F", "frequencia": 11694738 }, + { "nome": "ANA", "genero": "F", "frequencia": 3079729 }, + { "nome": "FRANCISCA", "genero": "F", "frequencia": 721637 }, + { "nome": "ANTONIA", "genero": "F", "frequencia": 588783 }, + { "nome": "ADRIANA", "genero": "F", "frequencia": 565621 }, + { "nome": "JULIANA", "genero": "F", "frequencia": 562589 }, + { "nome": "MARCIA", "genero": "F", "frequencia": 551855 }, + { "nome": "FERNANDA", "genero": "F", "frequencia": 531607 }, + { "nome": "PATRICIA", "genero": "F", "frequencia": 529446 }, + { "nome": "ALINE", "genero": "F", "frequencia": 509869 }, + { "nome": "SANDRA", "genero": "F", "frequencia": 479230 }, + { "nome": "CAMILA", "genero": "F", "frequencia": 469851 }, + { "nome": "AMANDA", "genero": "F", "frequencia": 464624 }, + { "nome": "BRUNA", "genero": "F", "frequencia": 460770 }, + { "nome": "JESSICA", "genero": "F", "frequencia": 456472 }, + { "nome": "LETICIA", "genero": "F", "frequencia": 434056 }, + { "nome": "JULIA", "genero": "F", "frequencia": 430067 }, + { "nome": "LUCIANA", "genero": "F", "frequencia": 429769 }, + { "nome": "VANESSA", "genero": "F", "frequencia": 417512 }, + { "nome": "MARIANA", "genero": "F", "frequencia": 381778 }, + { "nome": "CLAUDIA", "genero": "F", "frequencia": 370000 }, + { "nome": "DANIELA", "genero": "F", "frequencia": 360000 }, + { "nome": "CRISTIANE", "genero": "F", "frequencia": 350000 }, + { "nome": "CRISTINA", "genero": "F", "frequencia": 340000 }, + { "nome": "DEBORA", "genero": "F", "frequencia": 330000 }, + { "nome": "DENISE", "genero": "F", "frequencia": 320000 }, + { "nome": "ELAINE", "genero": "F", "frequencia": 310000 }, + { "nome": "ERICA", "genero": "F", "frequencia": 300000 }, + { "nome": "FABIANA", "genero": "F", "frequencia": 290000 }, + { "nome": "GABRIELA", "genero": "F", "frequencia": 280000 }, + { "nome": "GISELE", "genero": "F", "frequencia": 270000 }, + { "nome": "INGRID", "genero": "F", "frequencia": 260000 }, + { "nome": "ISABELA", "genero": "F", "frequencia": 250000 }, + { "nome": "ISABEL", "genero": "F", "frequencia": 240000 }, + { "nome": "JAQUELINE", "genero": "F", "frequencia": 230000 }, + { "nome": "KAREN", "genero": "F", "frequencia": 220000 }, + { "nome": "KARLA", "genero": "F", "frequencia": 210000 }, + { "nome": "KARINE", "genero": "F", "frequencia": 200000 }, + { "nome": "LARA", "genero": "F", "frequencia": 195000 }, + { "nome": "LAURA", "genero": "F", "frequencia": 190000 }, + { "nome": "LARISSA", "genero": "F", "frequencia": 185000 }, + { "nome": "LUANA", "genero": "F", "frequencia": 180000 }, + { "nome": "MELISSA", "genero": "F", "frequencia": 175000 }, + { "nome": "MICHELE", "genero": "F", "frequencia": 170000 }, + { "nome": "MILENA", "genero": "F", "frequencia": 165000 }, + { "nome": "MONICA", "genero": "F", "frequencia": 160000 }, + { "nome": "NATHALIA", "genero": "F", "frequencia": 155000 }, + { "nome": "NATALIA", "genero": "F", "frequencia": 150000 }, + { "nome": "PAMELA", "genero": "F", "frequencia": 145000 }, + { "nome": "PAULA", "genero": "F", "frequencia": 140000 }, + { "nome": "PRISCILA", "genero": "F", "frequencia": 135000 }, + { "nome": "RAFAELA", "genero": "F", "frequencia": 130000 }, + { "nome": "RAQUEL", "genero": "F", "frequencia": 125000 }, + { "nome": "REGINA", "genero": "F", "frequencia": 120000 }, + { "nome": "RENATA", "genero": "F", "frequencia": 115000 }, + { "nome": "ROBERTA", "genero": "F", "frequencia": 110000 }, + { "nome": "ROSA", "genero": "F", "frequencia": 105000 }, + { "nome": "ROSANA", "genero": "F", "frequencia": 100000 }, + { "nome": "SABRINA", "genero": "F", "frequencia": 95000 }, + { "nome": "SAMANTHA", "genero": "F", "frequencia": 90000 }, + { "nome": "SARA", "genero": "F", "frequencia": 85000 }, + { "nome": "SIMONE", "genero": "F", "frequencia": 80000 }, + { "nome": "SONIA", "genero": "F", "frequencia": 75000 }, + { "nome": "SUELI", "genero": "F", "frequencia": 70000 }, + { "nome": "TALITA", "genero": "F", "frequencia": 65000 }, + { "nome": "TATIANA", "genero": "F", "frequencia": 60000 }, + { "nome": "THAIS", "genero": "F", "frequencia": 55000 }, + { "nome": "VERONICA", "genero": "F", "frequencia": 50000 }, + { "nome": "VIVIANE", "genero": "F", "frequencia": 45000 }, + { "nome": "YASMIN", "genero": "F", "frequencia": 40000 }, + { "nome": "YARA", "genero": "F", "frequencia": 38000 }, + { "nome": "BEATRIZ", "genero": "F", "frequencia": 36000 }, + { "nome": "BIANCA", "genero": "F", "frequencia": 34000 }, + { "nome": "CAROLINA", "genero": "F", "frequencia": 32000 }, + { "nome": "CARLA", "genero": "F", "frequencia": 30000 }, + { "nome": "CECILIA", "genero": "F", "frequencia": 28000 }, + { "nome": "DAIANE", "genero": "F", "frequencia": 26000 }, + { "nome": "ELISA", "genero": "F", "frequencia": 24000 }, + { "nome": "EMILIA", "genero": "F", "frequencia": 22000 }, + { "nome": "GIOVANNA", "genero": "F", "frequencia": 20000 }, + { "nome": "GLORIA", "genero": "F", "frequencia": 19000 }, + { "nome": "HELENA", "genero": "F", "frequencia": 18000 }, + { "nome": "HELOISA", "genero": "F", "frequencia": 17000 }, + { "nome": "INES", "genero": "F", "frequencia": 16000 }, + { "nome": "IRIS", "genero": "F", "frequencia": 15000 }, + { "nome": "JOANA", "genero": "F", "frequencia": 14000 }, + { "nome": "LEILA", "genero": "F", "frequencia": 13000 }, + { "nome": "LIDIA", "genero": "F", "frequencia": 12000 }, + { "nome": "LILIAN", "genero": "F", "frequencia": 11000 }, + { "nome": "LORENA", "genero": "F", "frequencia": 10500 }, + { "nome": "LUZIA", "genero": "F", "frequencia": 10000 }, + { "nome": "MADALENA", "genero": "F", "frequencia": 9500 }, + { "nome": "MIRIAM", "genero": "F", "frequencia": 9000 }, + { "nome": "NADIA", "genero": "F", "frequencia": 8500 }, + { "nome": "NOEMIA", "genero": "F", "frequencia": 8000 }, + { "nome": "ODETE", "genero": "F", "frequencia": 7500 }, + { "nome": "OLGA", "genero": "F", "frequencia": 7000 }, + { "nome": "RUTH", "genero": "F", "frequencia": 6500 }, + { "nome": "SELMA", "genero": "F", "frequencia": 6000 }, + { "nome": "SILVIA", "genero": "F", "frequencia": 5500 }, + { "nome": "SORAIA", "genero": "F", "frequencia": 5000 }, + { "nome": "SUZANA", "genero": "F", "frequencia": 4800 }, + { "nome": "TEREZA", "genero": "F", "frequencia": 4600 }, + { "nome": "TERESA", "genero": "F", "frequencia": 4400 }, + { "nome": "VALERIA", "genero": "F", "frequencia": 4200 }, + { "nome": "VITORIA", "genero": "F", "frequencia": 4000 }, + { "nome": "ZENAIDE", "genero": "F", "frequencia": 3800 }, + { "nome": "APARECIDA", "genero": "F", "frequencia": 3600 }, + { "nome": "BENEDITA", "genero": "F", "frequencia": 3400 }, + { "nome": "CONCEICAO", "genero": "F", "frequencia": 3200 }, + { "nome": "DALVA", "genero": "F", "frequencia": 3000 }, + { "nome": "DIRCE", "genero": "F", "frequencia": 2800 }, + { "nome": "EDNA", "genero": "F", "frequencia": 2600 }, + { "nome": "ELZA", "genero": "F", "frequencia": 2400 }, + { "nome": "EUNICE", "genero": "F", "frequencia": 2200 }, + { "nome": "FATIMA", "genero": "F", "frequencia": 2000 }, + { "nome": "GERALDA", "genero": "F", "frequencia": 1900 }, + { "nome": "HILDA", "genero": "F", "frequencia": 1800 }, + { "nome": "IRACEMA", "genero": "F", "frequencia": 1700 }, + { "nome": "IRENE", "genero": "F", "frequencia": 1600 }, + { "nome": "IOLANDA", "genero": "F", "frequencia": 1500 }, + { "nome": "IVONE", "genero": "F", "frequencia": 1400 }, + { "nome": "JANETE", "genero": "F", "frequencia": 1350 }, + { "nome": "JOSEFA", "genero": "F", "frequencia": 1300 }, + { "nome": "JOSIANE", "genero": "F", "frequencia": 1250 }, + { "nome": "LOURDES", "genero": "F", "frequencia": 1200 }, + { "nome": "LUCIENE", "genero": "F", "frequencia": 1150 }, + { "nome": "MARTA", "genero": "F", "frequencia": 1100 }, + { "nome": "MATILDE", "genero": "F", "frequencia": 1050 }, + { "nome": "NAIARA", "genero": "F", "frequencia": 1000 }, + { "nome": "NATALINA", "genero": "F", "frequencia": 950 }, + { "nome": "NAZARE", "genero": "F", "frequencia": 900 }, + { "nome": "NELMA", "genero": "F", "frequencia": 850 }, + { "nome": "NILDA", "genero": "F", "frequencia": 800 }, + { "nome": "OLINDA", "genero": "F", "frequencia": 750 }, + { "nome": "RAISSA", "genero": "F", "frequencia": 700 }, + { "nome": "RAMONA", "genero": "F", "frequencia": 680 }, + { "nome": "RAYSSA", "genero": "F", "frequencia": 660 }, + { "nome": "REBECA", "genero": "F", "frequencia": 640 }, + { "nome": "REJANE", "genero": "F", "frequencia": 620 }, + { "nome": "RITA", "genero": "F", "frequencia": 600 }, + { "nome": "ROSALIA", "genero": "F", "frequencia": 580 }, + { "nome": "ROSANGELA", "genero": "F", "frequencia": 560 }, + { "nome": "ROSELI", "genero": "F", "frequencia": 540 }, + { "nome": "ROSIMEIRE", "genero": "F", "frequencia": 520 }, + { "nome": "RUTE", "genero": "F", "frequencia": 500 }, + { "nome": "SALOME", "genero": "F", "frequencia": 480 }, + { "nome": "SELENE", "genero": "F", "frequencia": 460 }, + { "nome": "SHIRLEI", "genero": "F", "frequencia": 440 }, + { "nome": "SHIRLEY", "genero": "F", "frequencia": 420 }, + { "nome": "SILVANA", "genero": "F", "frequencia": 400 }, + { "nome": "SOCORRO", "genero": "F", "frequencia": 380 }, + { "nome": "SUELLEN", "genero": "F", "frequencia": 360 }, + { "nome": "TAINA", "genero": "F", "frequencia": 340 }, + { "nome": "TAMARA", "genero": "F", "frequencia": 320 }, + { "nome": "TAMIRES", "genero": "F", "frequencia": 300 }, + { "nome": "TANIA", "genero": "F", "frequencia": 280 }, + { "nome": "TATIANE", "genero": "F", "frequencia": 260 }, + { "nome": "THALITA", "genero": "F", "frequencia": 240 }, + { "nome": "VALENTINA", "genero": "F", "frequencia": 220 }, + { "nome": "VANDA", "genero": "F", "frequencia": 200 }, + { "nome": "VERA", "genero": "F", "frequencia": 190 }, + { "nome": "VILMA", "genero": "F", "frequencia": 180 }, + { "nome": "VIVIAN", "genero": "F", "frequencia": 170 }, + { "nome": "WANIA", "genero": "F", "frequencia": 160 }, + { "nome": "ALICE", "genero": "F", "frequencia": 150 }, + { "nome": "SOPHIA", "genero": "F", "frequencia": 140 }, + { "nome": "ISABELLA", "genero": "F", "frequencia": 130 }, + { "nome": "MANUELA", "genero": "F", "frequencia": 120 }, + { "nome": "LIVIA", "genero": "F", "frequencia": 110 }, + { "nome": "NAYARA", "genero": "F", "frequencia": 100 }, + { "nome": "ISADORA", "genero": "F", "frequencia": 90 }, + { "nome": "CLARA", "genero": "F", "frequencia": 80 }, + + { "nome": "ALEX", "genero": "N", "frequencia": 200000 }, + { "nome": "ARIEL", "genero": "N", "frequencia": 50000 }, + { "nome": "CHRISTIAN", "genero": "N", "frequencia": 40000 }, + { "nome": "RIAN", "genero": "N", "frequencia": 30000 }, + { "nome": "SASHA", "genero": "N", "frequencia": 20000 }, + { "nome": "DARA", "genero": "N", "frequencia": 15000 }, + { "nome": "EDER", "genero": "N", "frequencia": 10000 }, + { "nome": "GIOVANI", "genero": "N", "frequencia": 8000 } +] diff --git a/src/Nalu.Jobs/Data/names_curated_en.json b/src/Nalu.Jobs/Data/names_curated_en.json new file mode 100644 index 0000000..0456534 --- /dev/null +++ b/src/Nalu.Jobs/Data/names_curated_en.json @@ -0,0 +1,225 @@ +[ + {"nome": "JAMES", "genero": "M", "frequencia": 4900000}, + {"nome": "JOHN", "genero": "M", "frequencia": 4700000}, + {"nome": "ROBERT", "genero": "M", "frequencia": 4500000}, + {"nome": "MICHAEL", "genero": "M", "frequencia": 4300000}, + {"nome": "WILLIAM", "genero": "M", "frequencia": 4100000}, + {"nome": "DAVID", "genero": "M", "frequencia": 3900000}, + {"nome": "RICHARD", "genero": "M", "frequencia": 3200000}, + {"nome": "JOSEPH", "genero": "M", "frequencia": 3100000}, + {"nome": "THOMAS", "genero": "M", "frequencia": 3000000}, + {"nome": "CHARLES", "genero": "M", "frequencia": 2900000}, + {"nome": "CHRISTOPHER","genero": "M", "frequencia": 2700000}, + {"nome": "DANIEL", "genero": "M", "frequencia": 2600000}, + {"nome": "MATTHEW", "genero": "M", "frequencia": 2500000}, + {"nome": "ANTHONY", "genero": "M", "frequencia": 2400000}, + {"nome": "MARK", "genero": "M", "frequencia": 2300000}, + {"nome": "DONALD", "genero": "M", "frequencia": 2200000}, + {"nome": "STEVEN", "genero": "M", "frequencia": 2100000}, + {"nome": "PAUL", "genero": "M", "frequencia": 2000000}, + {"nome": "ANDREW", "genero": "M", "frequencia": 1900000}, + {"nome": "KENNETH", "genero": "M", "frequencia": 1800000}, + {"nome": "GEORGE", "genero": "M", "frequencia": 1750000}, + {"nome": "JOSHUA", "genero": "M", "frequencia": 1700000}, + {"nome": "KEVIN", "genero": "M", "frequencia": 1650000}, + {"nome": "BRIAN", "genero": "M", "frequencia": 1600000}, + {"nome": "EDWARD", "genero": "M", "frequencia": 1550000}, + {"nome": "RONALD", "genero": "M", "frequencia": 1500000}, + {"nome": "TIMOTHY", "genero": "M", "frequencia": 1450000}, + {"nome": "JASON", "genero": "M", "frequencia": 1400000}, + {"nome": "JEFFREY", "genero": "M", "frequencia": 1350000}, + {"nome": "RYAN", "genero": "M", "frequencia": 1300000}, + {"nome": "JACOB", "genero": "M", "frequencia": 1280000}, + {"nome": "GARY", "genero": "M", "frequencia": 1260000}, + {"nome": "NICHOLAS", "genero": "M", "frequencia": 1240000}, + {"nome": "ERIC", "genero": "M", "frequencia": 1220000}, + {"nome": "JONATHAN", "genero": "M", "frequencia": 1200000}, + {"nome": "STEPHEN", "genero": "M", "frequencia": 1180000}, + {"nome": "LARRY", "genero": "M", "frequencia": 1160000}, + {"nome": "JUSTIN", "genero": "M", "frequencia": 1140000}, + {"nome": "SCOTT", "genero": "M", "frequencia": 1120000}, + {"nome": "BRANDON", "genero": "M", "frequencia": 1100000}, + {"nome": "FRANK", "genero": "M", "frequencia": 1080000}, + {"nome": "RAYMOND", "genero": "M", "frequencia": 1060000}, + {"nome": "GREGORY", "genero": "M", "frequencia": 1040000}, + {"nome": "SAMUEL", "genero": "M", "frequencia": 1020000}, + {"nome": "PATRICK", "genero": "M", "frequencia": 1000000}, + {"nome": "BENJAMIN", "genero": "M", "frequencia": 980000}, + {"nome": "JACK", "genero": "M", "frequencia": 960000}, + {"nome": "DENNIS", "genero": "M", "frequencia": 940000}, + {"nome": "JERRY", "genero": "M", "frequencia": 920000}, + {"nome": "ALEXANDER", "genero": "M", "frequencia": 900000}, + {"nome": "TYLER", "genero": "M", "frequencia": 880000}, + {"nome": "DOUGLAS", "genero": "M", "frequencia": 860000}, + {"nome": "HENRY", "genero": "M", "frequencia": 840000}, + {"nome": "PETER", "genero": "M", "frequencia": 820000}, + {"nome": "ADAM", "genero": "M", "frequencia": 800000}, + {"nome": "NATHAN", "genero": "M", "frequencia": 780000}, + {"nome": "ZACHARY", "genero": "M", "frequencia": 760000}, + {"nome": "WALTER", "genero": "M", "frequencia": 740000}, + {"nome": "KYLE", "genero": "M", "frequencia": 720000}, + {"nome": "HAROLD", "genero": "M", "frequencia": 700000}, + {"nome": "CARL", "genero": "M", "frequencia": 680000}, + {"nome": "ARTHUR", "genero": "M", "frequencia": 660000}, + {"nome": "GERALD", "genero": "M", "frequencia": 640000}, + {"nome": "ROGER", "genero": "M", "frequencia": 620000}, + {"nome": "KEITH", "genero": "M", "frequencia": 600000}, + {"nome": "JEREMY", "genero": "M", "frequencia": 580000}, + {"nome": "TERRY", "genero": "M", "frequencia": 560000}, + {"nome": "LAWRENCE", "genero": "M", "frequencia": 540000}, + {"nome": "SEAN", "genero": "M", "frequencia": 520000}, + {"nome": "ALBERT", "genero": "M", "frequencia": 500000}, + {"nome": "DYLAN", "genero": "M", "frequencia": 490000}, + {"nome": "AUSTIN", "genero": "M", "frequencia": 480000}, + {"nome": "NOAH", "genero": "M", "frequencia": 470000}, + {"nome": "ETHAN", "genero": "M", "frequencia": 460000}, + {"nome": "CHRISTIAN", "genero": "M", "frequencia": 450000}, + {"nome": "ALAN", "genero": "M", "frequencia": 440000}, + {"nome": "WAYNE", "genero": "M", "frequencia": 430000}, + {"nome": "ROY", "genero": "M", "frequencia": 420000}, + {"nome": "RALPH", "genero": "M", "frequencia": 410000}, + {"nome": "EUGENE", "genero": "M", "frequencia": 400000}, + {"nome": "RUSSELL", "genero": "M", "frequencia": 390000}, + {"nome": "BOBBY", "genero": "M", "frequencia": 380000}, + {"nome": "PHILIP", "genero": "M", "frequencia": 370000}, + {"nome": "WAYNE", "genero": "M", "frequencia": 360000}, + {"nome": "LOUIS", "genero": "M", "frequencia": 350000}, + {"nome": "BILLY", "genero": "M", "frequencia": 340000}, + {"nome": "WILLIE", "genero": "M", "frequencia": 330000}, + {"nome": "BRUCE", "genero": "M", "frequencia": 320000}, + {"nome": "LIAM", "genero": "M", "frequencia": 310000}, + {"nome": "MASON", "genero": "M", "frequencia": 300000}, + {"nome": "LOGAN", "genero": "M", "frequencia": 290000}, + {"nome": "LUCAS", "genero": "M", "frequencia": 280000}, + {"nome": "OLIVER", "genero": "M", "frequencia": 270000}, + {"nome": "AIDEN", "genero": "M", "frequencia": 260000}, + {"nome": "CALEB", "genero": "M", "frequencia": 250000}, + {"nome": "ELIJAH", "genero": "M", "frequencia": 240000}, + {"nome": "GABRIEL", "genero": "M", "frequencia": 230000}, + {"nome": "ISAIAH", "genero": "M", "frequencia": 220000}, + {"nome": "HUNTER", "genero": "M", "frequencia": 210000}, + {"nome": "EVAN", "genero": "M", "frequencia": 200000}, + {"nome": "COLE", "genero": "M", "frequencia": 190000}, + {"nome": "CAMERON", "genero": "M", "frequencia": 180000}, + {"nome": "SEAN", "genero": "M", "frequencia": 170000}, + {"nome": "CONNOR", "genero": "M", "frequencia": 160000}, + {"nome": "CHARLIE", "genero": "M", "frequencia": 150000}, + {"nome": "MIKE", "genero": "M", "frequencia": 140000}, + {"nome": "JIMMY", "genero": "M", "frequencia": 130000}, + {"nome": "TOM", "genero": "M", "frequencia": 120000}, + {"nome": "JIM", "genero": "M", "frequencia": 110000}, + {"nome": "BOB", "genero": "M", "frequencia": 100000}, + {"nome": "BILL", "genero": "M", "frequencia": 90000}, + {"nome": "ALEX", "genero": "N", "frequencia": 80000}, + {"nome": "ANDY", "genero": "N", "frequencia": 70000}, + {"nome": "CHRIS", "genero": "N", "frequencia": 60000}, + {"nome": "SAM", "genero": "N", "frequencia": 50000}, + {"nome": "JORDAN", "genero": "N", "frequencia": 48000}, + {"nome": "TAYLOR", "genero": "N", "frequencia": 46000}, + {"nome": "MORGAN", "genero": "N", "frequencia": 44000}, + {"nome": "CASEY", "genero": "N", "frequencia": 42000}, + {"nome": "RILEY", "genero": "N", "frequencia": 40000}, + {"nome": "JAMIE", "genero": "N", "frequencia": 38000}, + {"nome": "MARY", "genero": "F", "frequencia": 3800000}, + {"nome": "PATRICIA", "genero": "F", "frequencia": 3600000}, + {"nome": "JENNIFER", "genero": "F", "frequencia": 3400000}, + {"nome": "LINDA", "genero": "F", "frequencia": 3200000}, + {"nome": "BARBARA", "genero": "F", "frequencia": 3000000}, + {"nome": "ELIZABETH", "genero": "F", "frequencia": 2800000}, + {"nome": "SUSAN", "genero": "F", "frequencia": 2600000}, + {"nome": "JESSICA", "genero": "F", "frequencia": 2400000}, + {"nome": "SARAH", "genero": "F", "frequencia": 2200000}, + {"nome": "KAREN", "genero": "F", "frequencia": 2000000}, + {"nome": "LISA", "genero": "F", "frequencia": 1900000}, + {"nome": "NANCY", "genero": "F", "frequencia": 1800000}, + {"nome": "BETTY", "genero": "F", "frequencia": 1700000}, + {"nome": "MARGARET", "genero": "F", "frequencia": 1600000}, + {"nome": "SANDRA", "genero": "F", "frequencia": 1500000}, + {"nome": "ASHLEY", "genero": "F", "frequencia": 1400000}, + {"nome": "EMILY", "genero": "F", "frequencia": 1350000}, + {"nome": "DOROTHY", "genero": "F", "frequencia": 1300000}, + {"nome": "KIMBERLY", "genero": "F", "frequencia": 1250000}, + {"nome": "AMY", "genero": "F", "frequencia": 1200000}, + {"nome": "SHIRLEY", "genero": "F", "frequencia": 1150000}, + {"nome": "ANGELA", "genero": "F", "frequencia": 1100000}, + {"nome": "HELEN", "genero": "F", "frequencia": 1050000}, + {"nome": "BRENDA", "genero": "F", "frequencia": 1000000}, + {"nome": "AMANDA", "genero": "F", "frequencia": 960000}, + {"nome": "STEPHANIE", "genero": "F", "frequencia": 920000}, + {"nome": "MELISSA", "genero": "F", "frequencia": 880000}, + {"nome": "DEBORAH", "genero": "F", "frequencia": 840000}, + {"nome": "RACHEL", "genero": "F", "frequencia": 800000}, + {"nome": "LAURA", "genero": "F", "frequencia": 760000}, + {"nome": "SHARON", "genero": "F", "frequencia": 720000}, + {"nome": "CYNTHIA", "genero": "F", "frequencia": 680000}, + {"nome": "KATHLEEN", "genero": "F", "frequencia": 660000}, + {"nome": "CHRISTINA", "genero": "F", "frequencia": 640000}, + {"nome": "VIRGINIA", "genero": "F", "frequencia": 620000}, + {"nome": "KATHERINE", "genero": "F", "frequencia": 600000}, + {"nome": "REBECCA", "genero": "F", "frequencia": 580000}, + {"nome": "JUDITH", "genero": "F", "frequencia": 560000}, + {"nome": "KELLY", "genero": "F", "frequencia": 540000}, + {"nome": "CHRISTINE", "genero": "F", "frequencia": 520000}, + {"nome": "DEBRA", "genero": "F", "frequencia": 500000}, + {"nome": "JOAN", "genero": "F", "frequencia": 480000}, + {"nome": "MARTHA", "genero": "F", "frequencia": 460000}, + {"nome": "EMMA", "genero": "F", "frequencia": 440000}, + {"nome": "OLIVIA", "genero": "F", "frequencia": 420000}, + {"nome": "MADISON", "genero": "F", "frequencia": 400000}, + {"nome": "ISABELLA", "genero": "F", "frequencia": 390000}, + {"nome": "SOPHIA", "genero": "F", "frequencia": 380000}, + {"nome": "MIA", "genero": "F", "frequencia": 370000}, + {"nome": "CHARLOTTE", "genero": "F", "frequencia": 360000}, + {"nome": "ABIGAIL", "genero": "F", "frequencia": 350000}, + {"nome": "VICTORIA", "genero": "F", "frequencia": 340000}, + {"nome": "GRACE", "genero": "F", "frequencia": 330000}, + {"nome": "SAMANTHA", "genero": "F", "frequencia": 320000}, + {"nome": "HANNAH", "genero": "F", "frequencia": 310000}, + {"nome": "EVELYN", "genero": "F", "frequencia": 300000}, + {"nome": "NICOLE", "genero": "F", "frequencia": 290000}, + {"nome": "DONNA", "genero": "F", "frequencia": 280000}, + {"nome": "CAROL", "genero": "F", "frequencia": 270000}, + {"nome": "RUTH", "genero": "F", "frequencia": 260000}, + {"nome": "DIANA", "genero": "F", "frequencia": 250000}, + {"nome": "ALICE", "genero": "F", "frequencia": 240000}, + {"nome": "JULIE", "genero": "F", "frequencia": 230000}, + {"nome": "HEATHER", "genero": "F", "frequencia": 220000}, + {"nome": "TERESA", "genero": "F", "frequencia": 210000}, + {"nome": "GLORIA", "genero": "F", "frequencia": 200000}, + {"nome": "CAROLYN", "genero": "F", "frequencia": 190000}, + {"nome": "JANET", "genero": "F", "frequencia": 180000}, + {"nome": "CHERYL", "genero": "F", "frequencia": 170000}, + {"nome": "FRANCES", "genero": "F", "frequencia": 160000}, + {"nome": "AMBER", "genero": "F", "frequencia": 150000}, + {"nome": "MARIE", "genero": "F", "frequencia": 140000}, + {"nome": "JACQUELINE", "genero": "F", "frequencia": 130000}, + {"nome": "ROSE", "genero": "F", "frequencia": 120000}, + {"nome": "WANDA", "genero": "F", "frequencia": 110000}, + {"nome": "JULIA", "genero": "F", "frequencia": 105000}, + {"nome": "TIFFANY", "genero": "F", "frequencia": 100000}, + {"nome": "NATALIE", "genero": "F", "frequencia": 96000}, + {"nome": "BEVERLY", "genero": "F", "frequencia": 92000}, + {"nome": "DENISE", "genero": "F", "frequencia": 88000}, + {"nome": "THERESA", "genero": "F", "frequencia": 84000}, + {"nome": "DANIELLE", "genero": "F", "frequencia": 80000}, + {"nome": "MARILYN", "genero": "F", "frequencia": 76000}, + {"nome": "LAUREN", "genero": "F", "frequencia": 72000}, + {"nome": "BRITTANY", "genero": "F", "frequencia": 68000}, + {"nome": "CHLOE", "genero": "F", "frequencia": 64000}, + {"nome": "ELEANOR", "genero": "F", "frequencia": 60000}, + {"nome": "CLAIRE", "genero": "F", "frequencia": 56000}, + {"nome": "RUBY", "genero": "F", "frequencia": 52000}, + {"nome": "ELLIE", "genero": "F", "frequencia": 48000}, + {"nome": "NORA", "genero": "F", "frequencia": 44000}, + {"nome": "HAZEL", "genero": "F", "frequencia": 40000}, + {"nome": "VIOLET", "genero": "F", "frequencia": 36000}, + {"nome": "AURORA", "genero": "F", "frequencia": 32000}, + {"nome": "ZOE", "genero": "F", "frequencia": 28000}, + {"nome": "PENELOPE", "genero": "F", "frequencia": 24000}, + {"nome": "LILLIAN", "genero": "F", "frequencia": 20000}, + {"nome": "ADDISON", "genero": "F", "frequencia": 18000}, + {"nome": "AUBREY", "genero": "F", "frequencia": 16000}, + {"nome": "ELIANA", "genero": "F", "frequencia": 14000}, + {"nome": "LAYLA", "genero": "F", "frequencia": 12000}, + {"nome": "SCARLETT", "genero": "F", "frequencia": 10000} +] diff --git a/src/Nalu.Jobs/Data/names_curated_es.json b/src/Nalu.Jobs/Data/names_curated_es.json new file mode 100644 index 0000000..d3e99df --- /dev/null +++ b/src/Nalu.Jobs/Data/names_curated_es.json @@ -0,0 +1,169 @@ +[ + {"nome": "JOSÉ", "genero": "M", "frequencia": 5000000}, + {"nome": "JOSE", "genero": "M", "frequencia": 5000000}, + {"nome": "JUAN", "genero": "M", "frequencia": 4500000}, + {"nome": "CARLOS", "genero": "M", "frequencia": 4200000}, + {"nome": "MIGUEL", "genero": "M", "frequencia": 3800000}, + {"nome": "ANTONIO", "genero": "M", "frequencia": 3600000}, + {"nome": "LUIS", "genero": "M", "frequencia": 3400000}, + {"nome": "FRANCISCO", "genero": "M", "frequencia": 3200000}, + {"nome": "MANUEL", "genero": "M", "frequencia": 3000000}, + {"nome": "ALEJANDRO", "genero": "M", "frequencia": 2800000}, + {"nome": "PEDRO", "genero": "M", "frequencia": 2600000}, + {"nome": "JESÚS", "genero": "M", "frequencia": 2400000}, + {"nome": "JESUS", "genero": "M", "frequencia": 2400000}, + {"nome": "JAVIER", "genero": "M", "frequencia": 2200000}, + {"nome": "FERNANDO", "genero": "M", "frequencia": 2000000}, + {"nome": "RAFAEL", "genero": "M", "frequencia": 1900000}, + {"nome": "JORGE", "genero": "M", "frequencia": 1800000}, + {"nome": "SERGIO", "genero": "M", "frequencia": 1700000}, + {"nome": "PABLO", "genero": "M", "frequencia": 1600000}, + {"nome": "ALBERTO", "genero": "M", "frequencia": 1500000}, + {"nome": "GUSTAVO", "genero": "M", "frequencia": 1400000}, + {"nome": "RODRIGO", "genero": "M", "frequencia": 1300000}, + {"nome": "EDUARDO", "genero": "M", "frequencia": 1200000}, + {"nome": "ERNESTO", "genero": "M", "frequencia": 1100000}, + {"nome": "ROBERTO", "genero": "M", "frequencia": 1050000}, + {"nome": "ENRIQUE", "genero": "M", "frequencia": 1000000}, + {"nome": "ARTURO", "genero": "M", "frequencia": 950000}, + {"nome": "DIEGO", "genero": "M", "frequencia": 900000}, + {"nome": "HÉCTOR", "genero": "M", "frequencia": 870000}, + {"nome": "HECTOR", "genero": "M", "frequencia": 870000}, + {"nome": "MARIO", "genero": "M", "frequencia": 840000}, + {"nome": "IGNACIO", "genero": "M", "frequencia": 810000}, + {"nome": "JULIO", "genero": "M", "frequencia": 780000}, + {"nome": "GABRIEL", "genero": "M", "frequencia": 750000}, + {"nome": "ÓSCAR", "genero": "M", "frequencia": 720000}, + {"nome": "OSCAR", "genero": "M", "frequencia": 720000}, + {"nome": "FELIPE", "genero": "M", "frequencia": 700000}, + {"nome": "IVÁN", "genero": "M", "frequencia": 680000}, + {"nome": "IVAN", "genero": "M", "frequencia": 680000}, + {"nome": "SALVADOR", "genero": "M", "frequencia": 660000}, + {"nome": "MARCOS", "genero": "M", "frequencia": 640000}, + {"nome": "SANTIAGO", "genero": "M", "frequencia": 620000}, + {"nome": "VÍCTOR", "genero": "M", "frequencia": 600000}, + {"nome": "VICTOR", "genero": "M", "frequencia": 600000}, + {"nome": "NICOLÁS", "genero": "M", "frequencia": 580000}, + {"nome": "NICOLAS", "genero": "M", "frequencia": 580000}, + {"nome": "CÉSAR", "genero": "M", "frequencia": 560000}, + {"nome": "CESAR", "genero": "M", "frequencia": 560000}, + {"nome": "MAURICIO", "genero": "M", "frequencia": 540000}, + {"nome": "RUBÉN", "genero": "M", "frequencia": 520000}, + {"nome": "RUBEN", "genero": "M", "frequencia": 520000}, + {"nome": "HUGO", "genero": "M", "frequencia": 500000}, + {"nome": "RAÚL", "genero": "M", "frequencia": 480000}, + {"nome": "RAUL", "genero": "M", "frequencia": 480000}, + {"nome": "JOAQUÍN", "genero": "M", "frequencia": 460000}, + {"nome": "JOAQUIN", "genero": "M", "frequencia": 460000}, + {"nome": "ALFREDO", "genero": "M", "frequencia": 440000}, + {"nome": "ADRIÁN", "genero": "M", "frequencia": 420000}, + {"nome": "ADRIAN", "genero": "M", "frequencia": 420000}, + {"nome": "ANDRÉS", "genero": "M", "frequencia": 400000}, + {"nome": "ANDRES", "genero": "M", "frequencia": 400000}, + {"nome": "SEBASTIÁN", "genero": "M", "frequencia": 390000}, + {"nome": "SEBASTIAN", "genero": "M", "frequencia": 390000}, + {"nome": "MATEO", "genero": "M", "frequencia": 380000}, + {"nome": "CAMILO", "genero": "M", "frequencia": 370000}, + {"nome": "MARTIN", "genero": "M", "frequencia": 360000}, + {"nome": "MARTÍN", "genero": "M", "frequencia": 360000}, + {"nome": "EMILIO", "genero": "M", "frequencia": 350000}, + {"nome": "RAMÓN", "genero": "M", "frequencia": 340000}, + {"nome": "RAMON", "genero": "M", "frequencia": 340000}, + {"nome": "SANTIAGO", "genero": "M", "frequencia": 330000}, + {"nome": "TOMÁS", "genero": "M", "frequencia": 320000}, + {"nome": "TOMAS", "genero": "M", "frequencia": 320000}, + {"nome": "ESTEBAN", "genero": "M", "frequencia": 310000}, + {"nome": "CRISTIAN", "genero": "M", "frequencia": 300000}, + {"nome": "PACO", "genero": "M", "frequencia": 100000}, + {"nome": "NACHO", "genero": "M", "frequencia": 90000}, + {"nome": "PEPE", "genero": "M", "frequencia": 80000}, + {"nome": "FRAN", "genero": "M", "frequencia": 70000}, + {"nome": "SANTI", "genero": "M", "frequencia": 60000}, + {"nome": "ALEX", "genero": "N", "frequencia": 200000}, + {"nome": "DARÍO", "genero": "M", "frequencia": 150000}, + {"nome": "DARIO", "genero": "M", "frequencia": 150000}, + {"nome": "XAVIER", "genero": "M", "frequencia": 140000}, + {"nome": "IKER", "genero": "M", "frequencia": 130000}, + {"nome": "POL", "genero": "M", "frequencia": 90000}, + {"nome": "MARC", "genero": "M", "frequencia": 180000}, + {"nome": "PAU", "genero": "M", "frequencia": 110000}, + {"nome": "MARÍA", "genero": "F", "frequencia": 5500000}, + {"nome": "MARIA", "genero": "F", "frequencia": 5500000}, + {"nome": "ANA", "genero": "F", "frequencia": 4000000}, + {"nome": "SOFÍA", "genero": "F", "frequencia": 3500000}, + {"nome": "SOFIA", "genero": "F", "frequencia": 3500000}, + {"nome": "VALENTINA", "genero": "F", "frequencia": 3200000}, + {"nome": "CAMILA", "genero": "F", "frequencia": 3000000}, + {"nome": "LUCÍA", "genero": "F", "frequencia": 2800000}, + {"nome": "LUCIA", "genero": "F", "frequencia": 2800000}, + {"nome": "MARIANA", "genero": "F", "frequencia": 2600000}, + {"nome": "PAULA", "genero": "F", "frequencia": 2400000}, + {"nome": "ISABEL", "genero": "F", "frequencia": 2200000}, + {"nome": "CARMEN", "genero": "F", "frequencia": 2000000}, + {"nome": "ALEJANDRA", "genero": "F", "frequencia": 1900000}, + {"nome": "DIANA", "genero": "F", "frequencia": 1800000}, + {"nome": "ROSA", "genero": "F", "frequencia": 1700000}, + {"nome": "LAURA", "genero": "F", "frequencia": 1600000}, + {"nome": "ELENA", "genero": "F", "frequencia": 1500000}, + {"nome": "CARLA", "genero": "F", "frequencia": 1400000}, + {"nome": "FERNANDA", "genero": "F", "frequencia": 1300000}, + {"nome": "TERESA", "genero": "F", "frequencia": 1200000}, + {"nome": "CLAUDIA", "genero": "F", "frequencia": 1100000}, + {"nome": "ADRIANA", "genero": "F", "frequencia": 1050000}, + {"nome": "VERÓNICA", "genero": "F", "frequencia": 1000000}, + {"nome": "VERONICA", "genero": "F", "frequencia": 1000000}, + {"nome": "DANIELA", "genero": "F", "frequencia": 960000}, + {"nome": "GABRIELA", "genero": "F", "frequencia": 920000}, + {"nome": "PATRICIA", "genero": "F", "frequencia": 880000}, + {"nome": "NATALIA", "genero": "F", "frequencia": 840000}, + {"nome": "MÓNICA", "genero": "F", "frequencia": 800000}, + {"nome": "MONICA", "genero": "F", "frequencia": 800000}, + {"nome": "SANDRA", "genero": "F", "frequencia": 760000}, + {"nome": "PILAR", "genero": "F", "frequencia": 720000}, + {"nome": "LORENA", "genero": "F", "frequencia": 680000}, + {"nome": "CAROLINA", "genero": "F", "frequencia": 640000}, + {"nome": "PAOLA", "genero": "F", "frequencia": 600000}, + {"nome": "YOLANDA", "genero": "F", "frequencia": 560000}, + {"nome": "ANDREA", "genero": "F", "frequencia": 540000}, + {"nome": "ÁNGELA", "genero": "F", "frequencia": 520000}, + {"nome": "ANGELA", "genero": "F", "frequencia": 520000}, + {"nome": "BEATRIZ", "genero": "F", "frequencia": 500000}, + {"nome": "DOLORES", "genero": "F", "frequencia": 480000}, + {"nome": "GLORIA", "genero": "F", "frequencia": 460000}, + {"nome": "MARISOL", "genero": "F", "frequencia": 440000}, + {"nome": "MIRIAM", "genero": "F", "frequencia": 420000}, + {"nome": "NADIA", "genero": "F", "frequencia": 400000}, + {"nome": "SONIA", "genero": "F", "frequencia": 380000}, + {"nome": "SUSANA", "genero": "F", "frequencia": 360000}, + {"nome": "VANESSA", "genero": "F", "frequencia": 340000}, + {"nome": "REBECA", "genero": "F", "frequencia": 320000}, + {"nome": "INÉS", "genero": "F", "frequencia": 300000}, + {"nome": "INES", "genero": "F", "frequencia": 300000}, + {"nome": "ALICIA", "genero": "F", "frequencia": 290000}, + {"nome": "IRENE", "genero": "F", "frequencia": 280000}, + {"nome": "ROCÍO", "genero": "F", "frequencia": 270000}, + {"nome": "ROCIO", "genero": "F", "frequencia": 270000}, + {"nome": "LOLA", "genero": "F", "frequencia": 260000}, + {"nome": "MARTA", "genero": "F", "frequencia": 250000}, + {"nome": "SARA", "genero": "F", "frequencia": 240000}, + {"nome": "VICTORIA", "genero": "F", "frequencia": 230000}, + {"nome": "ALBA", "genero": "F", "frequencia": 220000}, + {"nome": "NEREA", "genero": "F", "frequencia": 210000}, + {"nome": "JULIA", "genero": "F", "frequencia": 200000}, + {"nome": "EMMA", "genero": "F", "frequencia": 190000}, + {"nome": "VALERIA", "genero": "F", "frequencia": 180000}, + {"nome": "LUNA", "genero": "F", "frequencia": 170000}, + {"nome": "MARTINA", "genero": "F", "frequencia": 160000}, + {"nome": "ISABELLA", "genero": "F", "frequencia": 150000}, + {"nome": "PALOMA", "genero": "F", "frequencia": 140000}, + {"nome": "CONSUELO", "genero": "F", "frequencia": 130000}, + {"nome": "ESPERANZA", "genero": "F", "frequencia": 120000}, + {"nome": "AMPARO", "genero": "F", "frequencia": 110000}, + {"nome": "MERCEDES", "genero": "F", "frequencia": 100000}, + {"nome": "CONCEPCIÓN", "genero": "F", "frequencia": 90000}, + {"nome": "CONCEPCION", "genero": "F", "frequencia": 90000}, + {"nome": "LUPE", "genero": "F", "frequencia": 80000}, + {"nome": "PILI", "genero": "F", "frequencia": 60000}, + {"nome": "MARI", "genero": "F", "frequencia": 50000}, + {"nome": "CRIS", "genero": "F", "frequencia": 40000} +] diff --git a/src/Nalu.Jobs/INameImporterJob.cs b/src/Nalu.Jobs/INameImporterJob.cs new file mode 100644 index 0000000..acb574c --- /dev/null +++ b/src/Nalu.Jobs/INameImporterJob.cs @@ -0,0 +1,10 @@ +namespace Nalu.Jobs; + +public interface INameImporterJob +{ + /// + /// Imports Brazilian names from IBGE into MongoDB. + /// + /// When true, reimports even if a successful run exists today. + Task ExecuteAsync(bool forceFull = false); +} diff --git a/src/Nalu.Jobs/IbgeClient.cs b/src/Nalu.Jobs/IbgeClient.cs new file mode 100644 index 0000000..1a609a1 --- /dev/null +++ b/src/Nalu.Jobs/IbgeClient.cs @@ -0,0 +1,73 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Nalu.Jobs; + +public static class IbgeClient +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true + }; + + private static readonly (string Url, string? Genero)[] Endpoints = + [ + ("https://servicodados.ibge.gov.br/api/v2/censos/nomes/ranking/", null), + ("https://servicodados.ibge.gov.br/api/v2/censos/nomes/ranking/?sexo=M", "M"), + ("https://servicodados.ibge.gov.br/api/v2/censos/nomes/ranking/?sexo=F", "F"), + ]; + + ///+ /// Fetches names from the 3 IBGE ranking endpoints and aggregates into a single map. + /// M+F overlap → genero becomes "N". + /// + public static async Task> FetchNamesAsync( + HttpClient http, + ILogger logger, + CancellationToken ct) + { + var aggregated = new Dictionary (StringComparer.OrdinalIgnoreCase); + + foreach (var (url, generoOverride) in Endpoints) + { + logger.LogInformation("GET {Url}", url); + + var response = await http.GetAsync(url, ct); + response.EnsureSuccessStatusCode(); + + var raw = await response.Content.ReadAsStringAsync(ct); + var groups = JsonSerializer.Deserialize (raw, JsonOpts) ?? []; + + foreach (var group in groups) + { + var groupGenero = generoOverride + ?? group.Sexo?.ToUpperInvariant() switch { "M" => "M", "F" => "F", _ => "N" }; + + foreach (var entry in group.Res) + { + if (string.IsNullOrWhiteSpace(entry.Nome)) continue; + + var nomeUpper = entry.Nome.Trim().ToUpperInvariant(); + + if (aggregated.TryGetValue(nomeUpper, out var existing)) + { + aggregated[nomeUpper] = existing with + { + Frequencia = Math.Max(existing.Frequencia, entry.Frequencia), + Genero = existing.Genero == groupGenero ? groupGenero : "N" + }; + } + else + { + aggregated[nomeUpper] = new AggregatedName(nomeUpper, entry.Frequencia, groupGenero); + } + } + + logger.LogDebug(" → {Count} entries (total unique: {Total})", group.Res.Length, aggregated.Count); + } + } + + logger.LogInformation("IBGE fetch complete — {Count} unique names", aggregated.Count); + return aggregated; + } +} diff --git a/src/Nalu.Jobs/Models.cs b/src/Nalu.Jobs/Models.cs new file mode 100644 index 0000000..cd99e5d --- /dev/null +++ b/src/Nalu.Jobs/Models.cs @@ -0,0 +1,98 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using System.Text.Json.Serialization; + +namespace Nalu.Jobs; + +// ── IBGE API response ────────────────────────────────────────────────────────── + +/// +/// Top-level element returned by /v2/censos/nomes/ranking/ (optionally ?sexo=M|F). +/// Shape: [{ "localidade": "BR", "sexo": "M"|"F"|null, "res": [...] }] +/// +public record IbgeRankingGroup +{ + [JsonPropertyName("localidade")] + public string Localidade { get; init; } = ""; + + ///null = overall, "M" = male, "F" = female + [JsonPropertyName("sexo")] + public string? Sexo { get; init; } + + [JsonPropertyName("res")] + public IbgeNameEntry[] Res { get; init; } = []; +} + +public record IbgeNameEntry +{ + [JsonPropertyName("nome")] + public string Nome { get; init; } = ""; + + [JsonPropertyName("frequencia")] + public long Frequencia { get; init; } + + [JsonPropertyName("ranking")] + public int Ranking { get; init; } +} + +// ── In-memory aggregation ────────────────────────────────────────────────────── + +public record AggregatedName(string Nome, long Frequencia, string Genero); + +// ── MongoDB documents ────────────────────────────────────────────────────────── + +public class NomeBr +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("nome")] + public string Nome { get; set; } = ""; + + [BsonElement("nome_display")] + public string NomeDisplay { get; set; } = ""; + + [BsonElement("tipo")] + public string Tipo { get; set; } = "primeiro_nome"; + + [BsonElement("frequencia")] + public long Frequencia { get; set; } + + [BsonElement("genero")] + public string Genero { get; set; } = "N"; + + [BsonElement("fonte")] + public string Fonte { get; set; } = "ibge_censo"; + + [BsonElement("ultima_atualizacao")] + public DateTime UltimaAtualizacao { get; set; } +} + +public class JobRun +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("job")] + public string Job { get; set; } = "NaluNameImporter"; + + [BsonElement("executado_em")] + public DateTime ExecutadoEm { get; set; } + + [BsonElement("total_processados")] + public int TotalProcessados { get; set; } + + [BsonElement("total_inseridos")] + public int TotalInseridos { get; set; } + + [BsonElement("total_atualizados")] + public int TotalAtualizados { get; set; } + + [BsonElement("status")] + public string Status { get; set; } = "sucesso"; + + [BsonElement("erro")] + public string? Erro { get; set; } +} diff --git a/src/Nalu.Jobs/Nalu.Jobs.csproj b/src/Nalu.Jobs/Nalu.Jobs.csproj new file mode 100644 index 0000000..7892d0c --- /dev/null +++ b/src/Nalu.Jobs/Nalu.Jobs.csproj @@ -0,0 +1,23 @@ ++ + diff --git a/src/Nalu.Jobs/NaluNameImporterJob.cs b/src/Nalu.Jobs/NaluNameImporterJob.cs new file mode 100644 index 0000000..e640962 --- /dev/null +++ b/src/Nalu.Jobs/NaluNameImporterJob.cs @@ -0,0 +1,117 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Nalu.Jobs; + +public class NaluNameImporterJob : INameImporterJob +{ + private readonly IMongoClient _mongoClient; + private readonly IConfiguration _config; + private readonly ILogger+ + +net8.0 +enable +enable +Nalu.Jobs +Nalu.Jobs ++ + ++ + + + + ++ + + _logger; + + public NaluNameImporterJob( + IMongoClient mongoClient, + IConfiguration config, + ILogger logger) + { + _mongoClient = mongoClient; + _config = config; + _logger = logger; + } + + public async Task ExecuteAsync(bool forceFull = false) + { + _logger.LogInformation("NaluNameImporter starting (forceFull={ForceFull})", forceFull); + + var db = ResolveDatabase(); + var repo = new NameRepository(db); + + var startedAt = DateTime.UtcNow; + int totalProcessed = 0, totalInserted = 0, totalUpdated = 0; + string? errorMessage = null; + + try + { + await repo.EnsureIndexesAsync(CancellationToken.None); + + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + http.DefaultRequestHeaders.UserAgent.ParseAdd("NaluNameImporter/1.0"); + + var names = await IbgeClient.FetchNamesAsync(http, _logger, CancellationToken.None); + + if (names.Count == 0) + throw new InvalidOperationException("IBGE returned 0 names."); + + _logger.LogInformation("Starting upsert of {Count} names", names.Count); + + var now = DateTime.UtcNow; + + foreach (var kv in names) + { + var wasInserted = await repo.UpsertAsync(kv.Value, now, CancellationToken.None); + + totalProcessed++; + if (wasInserted) totalInserted++; + else totalUpdated++; + + if (totalProcessed % 500 == 0 || totalProcessed == names.Count) + { + _logger.LogInformation( + "[{Processed}/{Total}] inserted={Inserted} updated={Updated}", + totalProcessed, names.Count, totalInserted, totalUpdated); + } + } + + _logger.LogInformation( + "NaluNameImporter done — processed={P} inserted={I} updated={U}", + totalProcessed, totalInserted, totalUpdated); + + // ── Step 2: import curated list ──────────────────────────────────── + _logger.LogInformation("Step 2: importing curated names list..."); + await CuratedNamesImporter.ImportAsync(db, _logger, CancellationToken.None); + } + catch (Exception ex) + { + errorMessage = ex.Message; + _logger.LogError(ex, "NaluNameImporter failed: {Message}", ex.Message); + throw; // re-throw so Hangfire marks job as failed and retries + } + finally + { + try + { + await repo.SaveJobRunAsync(new JobRun + { + Job = "NaluNameImporter", + ExecutadoEm = startedAt, + TotalProcessados = totalProcessed, + TotalInseridos = totalInserted, + TotalAtualizados = totalUpdated, + Status = errorMessage is null ? "sucesso" : "erro", + Erro = errorMessage + }, CancellationToken.None); + } + catch (Exception logEx) + { + _logger.LogWarning(logEx, "Failed to save job_run log"); + } + } + } + + private IMongoDatabase ResolveDatabase() + { + // Try the ASP.NET Core connection string first, then env-var style + var connStr = _config.GetConnectionString("MongoDB") + ?? _config["MONGO_CONNECTION_STRING"] + ?? "mongodb://localhost:27017"; + + // Database name: from MongoUrl path segment, then explicit config, then default + var mongoUrl = MongoDB.Driver.MongoUrl.Create(connStr); + var dbName = (!string.IsNullOrWhiteSpace(mongoUrl.DatabaseName) ? mongoUrl.DatabaseName : null) + ?? _config["MONGO_DATABASE"] + ?? "nalu"; + + return _mongoClient.GetDatabase(dbName); + } +} diff --git a/src/Nalu.Jobs/NameRepository.cs b/src/Nalu.Jobs/NameRepository.cs new file mode 100644 index 0000000..1c0c2dc --- /dev/null +++ b/src/Nalu.Jobs/NameRepository.cs @@ -0,0 +1,71 @@ +using MongoDB.Driver; + +namespace Nalu.Jobs; + +public class NameRepository +{ + private readonly IMongoCollection _nomes; + private readonly IMongoCollection _jobRuns; + + public NameRepository(IMongoDatabase db) + { + _nomes = db.GetCollection ("nomes_br"); + _jobRuns = db.GetCollection ("job_runs"); + } + + public async Task EnsureIndexesAsync(CancellationToken ct) + { + var model = new CreateIndexModel ( + Builders .IndexKeys.Ascending(x => x.Nome), + new CreateIndexOptions { Unique = true, Name = "idx_nome_unique" }); + + await _nomes.Indexes.CreateOneAsync(model, cancellationToken: ct); + } + + /// + /// Upserts a single name. + /// New doc: inserts all fields. Existing: increments frequencia, merges genero, updates timestamp. + /// Returns true if inserted (new), false if updated. + /// + public async TaskUpsertAsync(AggregatedName name, DateTime now, CancellationToken ct) + { + var nomeDisplay = ToTitleCase(name.Nome); + var filter = Builders .Filter.Eq(x => x.Nome, name.Nome); + var existing = await _nomes.Find(filter).FirstOrDefaultAsync(ct); + + UpdateDefinition update; + + if (existing is null) + { + update = Builders .Update + .SetOnInsert(x => x.Nome, name.Nome) + .SetOnInsert(x => x.NomeDisplay, nomeDisplay) + .SetOnInsert(x => x.Tipo, "primeiro_nome") + .SetOnInsert(x => x.Genero, name.Genero) + .SetOnInsert(x => x.Fonte, "ibge_censo") + .Inc(x => x.Frequencia, name.Frequencia) + .Set(x => x.UltimaAtualizacao, now); + } + else + { + var mergedGenero = existing.Genero == name.Genero ? existing.Genero : "N"; + update = Builders .Update + .Inc(x => x.Frequencia, name.Frequencia) + .Set(x => x.Genero, mergedGenero) + .Set(x => x.UltimaAtualizacao, now); + } + + var result = await _nomes.UpdateOneAsync(filter, update, + new UpdateOptions { IsUpsert = true }, ct); + + return result.UpsertedId is not null; + } + + public async Task SaveJobRunAsync(JobRun run, CancellationToken ct) => + await _jobRuns.InsertOneAsync(run, null, ct); + + private static string ToTitleCase(string upper) => + string.Join(" ", upper.Split(' ') + .Select(w => w.Length == 0 ? w : + char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant())); +} diff --git a/src/Nalu.NameImporter/Nalu.NameImporter.csproj b/src/Nalu.NameImporter/Nalu.NameImporter.csproj new file mode 100644 index 0000000..3ad0b6d --- /dev/null +++ b/src/Nalu.NameImporter/Nalu.NameImporter.csproj @@ -0,0 +1,32 @@ + + + diff --git a/src/Nalu.NameImporter/Program.cs b/src/Nalu.NameImporter/Program.cs new file mode 100644 index 0000000..88a1712 --- /dev/null +++ b/src/Nalu.NameImporter/Program.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; +using Nalu.Jobs; + +// ── Graceful shutdown ────────────────────────────────────────────────────────── +using var cts = new CancellationTokenSource(); +Console.CancelKeyPress += (_, e) => +{ + e.Cancel = true; + Console.WriteLine("Shutdown requested — finishing gracefully..."); + cts.Cancel(); +}; + +// ── Configuration ────────────────────────────────────────────────────────────── +var config = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .Build(); + +bool forceFull = args.Contains("--force-full"); + +// ── DI ────────────────────────────────────────────────────────────────────────── +var connStr = config["MONGO_CONNECTION_STRING"] ?? "mongodb://localhost:27017"; + +var services = new ServiceCollection(); +services.AddSingleton+ + +Exe +net8.0 +enable +enable +Nalu.NameImporter +Nalu.NameImporter +enable ++ + ++ + + + + + + + ++ + + ++ +Always +(config); +services.AddSingleton (_ => new MongoClient(connStr)); +services.AddSingleton (); +services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Information)); + +await using var provider = services.BuildServiceProvider(); + +// ── Run ──────────────────────────────────────────────────────────────────────── +Console.WriteLine("═══════════════════════════════════════════"); +Console.WriteLine(" NALU Name Importer — IBGE Census Names"); +Console.WriteLine("═══════════════════════════════════════════"); +Console.WriteLine($" Force : {forceFull}"); +Console.WriteLine(); + +try +{ + var job = provider.GetRequiredService (); + await job.ExecuteAsync(forceFull); + return 0; +} +catch (OperationCanceledException) +{ + Console.Error.WriteLine("Cancelled."); + return 1; +} +catch (Exception ex) +{ + Console.Error.WriteLine($"ERROR: {ex.Message}"); + return 1; +} diff --git a/src/Nalu.NameImporter/appsettings.json b/src/Nalu.NameImporter/appsettings.json new file mode 100644 index 0000000..12f99d4 --- /dev/null +++ b/src/Nalu.NameImporter/appsettings.json @@ -0,0 +1,4 @@ +{ + "MONGO_CONNECTION_STRING": "mongodb://localhost:27017", + "MONGO_DATABASE": "nalu" +} diff --git a/src/Nalu.Web/Endpoints/ExtractEndpoints.cs b/src/Nalu.Web/Endpoints/ExtractEndpoints.cs index 7b57079..2cad98b 100644 --- a/src/Nalu.Web/Endpoints/ExtractEndpoints.cs +++ b/src/Nalu.Web/Endpoints/ExtractEndpoints.cs @@ -118,15 +118,29 @@ public static class ExtractEndpoints group.MapPost("/name", async (HttpContext ctx, [FromBody] ExtractionRequest req, ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => + { + var cr = await credits.TryConsumeAsync(ctx.User, "validate_name", ctx, ct); + credits.ApplyHeaders(ctx, cr); + if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); + return Results.Ok(await pipeline.ExecuteAsync("validate_name", req, ct)); + }) + .WithName("ExtractName") + .WithSummary("Extrai nome ou apelido") + .WithDescription("Extrai o nome ou apelido do usuário. Aceita primeiro nome sem sobrenome. Custa 3 créditos.") + .Produces ().ProducesProblem(429).WithOpenApi(); + + group.MapPost("/full-name", async (HttpContext ctx, + [FromBody] ExtractionRequest req, + ExtractionPipeline pipeline, CreditService credits, CancellationToken ct) => { var cr = await credits.TryConsumeAsync(ctx.User, "validate_full_name", ctx, ct); credits.ApplyHeaders(ctx, cr); if (!cr.Success) return Results.Json(cr.ErrorPayload, statusCode: 429); return Results.Ok(await pipeline.ExecuteAsync("validate_full_name", req, ct)); }) - .WithName("ExtractName") - .WithSummary("Extrai nome completo") - .WithDescription("Detecta e valida o nome completo do usuário a partir do diálogo. Custa 2 créditos.") + .WithName("ExtractFullName") + .WithSummary("Extrai nome completo com sobrenome") + .WithDescription("Extrai o nome completo do usuário. Exige sobrenome — retorna certain: false se apenas o primeiro nome for informado. Custa 3 créditos.") .Produces ().ProducesProblem(429).WithOpenApi(); group.MapPost("/yes-no", async (HttpContext ctx, diff --git a/src/Nalu.Web/Endpoints/PlaygroundEndpoints.cs b/src/Nalu.Web/Endpoints/PlaygroundEndpoints.cs index 376a8af..889a496 100644 --- a/src/Nalu.Web/Endpoints/PlaygroundEndpoints.cs +++ b/src/Nalu.Web/Endpoints/PlaygroundEndpoints.cs @@ -1,9 +1,7 @@ -using System.Collections.Concurrent; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using Nalu.Web.Models; using Nalu.Web.Services; -using Nalu.Web.Services.LlmRouter; namespace Nalu.Web.Endpoints; @@ -13,6 +11,7 @@ public static class PlaygroundEndpoints public static void MapPlaygroundEndpoints(this WebApplication app) { + // Extraction validators — ExtractionRequest body app.MapPost("/v1/playground/extract/{validator}", async ( string validator, HttpContext ctx, @@ -21,25 +20,10 @@ public static class PlaygroundEndpoints IMemoryCache cache, CancellationToken ct) => { - // IP-based rate limit: 10 calls/IP/day - var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - var cacheKey = $"pg:{ip}:{DateTime.UtcNow:yyyyMMdd}"; - - var count = cache.GetOrCreate(cacheKey, e => - { - e.AbsoluteExpiration = DateTime.UtcNow.Date.AddDays(1); - return 0; - }); - - if (count >= DailyLimit) + if (!CheckRateLimit(ctx, cache, out var remaining)) return Results.Json(new { error = "Limite diário de 10 chamadas atingido. Crie uma conta para 3.000 créditos grátis." }, statusCode: 429); - cache.Set(cacheKey, count + 1, new MemoryCacheEntryOptions - { - AbsoluteExpiration = DateTime.UtcNow.Date.AddDays(1) - }); - - ctx.Response.Headers["X-Playground-Calls-Remaining"] = (DailyLimit - count - 1).ToString(); + ctx.Response.Headers["X-Playground-Calls-Remaining"] = remaining.ToString(); try { @@ -54,5 +38,58 @@ public static class PlaygroundEndpoints .AllowAnonymous() .WithTags("Playground") .WithSummary("Playground — sem auth, 10 chamadas/IP/dia"); + + // validate_reply — ReplyRequest body (different schema) + app.MapPost("/v1/playground/extract/reply", async ( + HttpContext ctx, + [FromBody] ReplyRequest req, + ReplyService replyService, + IMemoryCache cache, + CancellationToken ct) => + { + if (!CheckRateLimit(ctx, cache, out var remaining)) + return Results.Json(new { error = "Limite diário de 10 chamadas atingido. Crie uma conta para 3.000 créditos grátis." }, statusCode: 429); + + ctx.Response.Headers["X-Playground-Calls-Remaining"] = remaining.ToString(); + + try + { + var result = await replyService.AnalyzeAsync(req, ct); + return Results.Ok(result); + } + catch (Exception ex) + { + return Results.Json(new { error = ex.Message }, statusCode: 503); + } + }) + .AllowAnonymous() + .WithTags("Playground") + .WithSummary("Playground validate_reply — sem auth, 10 chamadas/IP/dia"); + } + + private static bool CheckRateLimit(HttpContext ctx, IMemoryCache cache, out int remaining) + { + var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + var cacheKey = $"pg:{ip}:{DateTime.UtcNow:yyyyMMdd}"; + + var count = cache.GetOrCreate(cacheKey, e => + { + e.AbsoluteExpiration = DateTime.UtcNow.Date.AddDays(1); + return 0; + }); + + if (count >= DailyLimit) + { + remaining = 0; + return false; + } + + cache.Set(cacheKey, count + 1, new MemoryCacheEntryOptions + { + AbsoluteExpiration = DateTime.UtcNow.Date.AddDays(1) + }); + + remaining = DailyLimit - count - 1; + return true; } } diff --git a/src/Nalu.Web/Endpoints/StripeEndpoints.cs b/src/Nalu.Web/Endpoints/StripeEndpoints.cs new file mode 100644 index 0000000..258a36a --- /dev/null +++ b/src/Nalu.Web/Endpoints/StripeEndpoints.cs @@ -0,0 +1,241 @@ +using Nalu.Web.Data.Repositories; +using Stripe; +using Stripe.Checkout; +using NaluSubscription = Nalu.Web.Data.Models.Subscription; + +namespace Nalu.Web.Endpoints; + +public static class StripeEndpoints +{ + public static void MapStripeEndpoints(this WebApplication app) + { + app.MapPost("/webhooks/stripe", async ( + HttpContext ctx, + IConfiguration config, + WebhookEventRepository webhookEvents, + SubscriptionRepository subscriptions, + UserRepository users, + ILogger logger, + CancellationToken ct) => + { + var webhookSecret = config["Stripe:WebhookSecret"] ?? ""; + if (string.IsNullOrEmpty(webhookSecret)) + { + logger.LogError("Stripe:WebhookSecret not configured"); + return Results.StatusCode(500); + } + + string json; + using (var reader = new StreamReader(ctx.Request.Body)) + json = await reader.ReadToEndAsync(ct); + + var signature = ctx.Request.Headers["Stripe-Signature"].FirstOrDefault(); + if (string.IsNullOrEmpty(signature)) + return Results.BadRequest("Missing Stripe-Signature"); + + Event stripeEvent; + try + { + stripeEvent = EventUtility.ConstructEvent(json, signature, webhookSecret, + throwOnApiVersionMismatch: false); + } + catch (StripeException ex) + { + logger.LogWarning("Stripe signature validation failed: {Msg}", ex.Message); + return Results.BadRequest("Invalid signature"); + } + + // ── Idempotency check ───────────────────────────────────────────── + var isNew = await webhookEvents.TryInsertAsync(stripeEvent.Id, stripeEvent.Type, ct); + if (!isNew) + { + logger.LogInformation("Duplicate webhook {EventId} ({Type}) — skipped", stripeEvent.Id, stripeEvent.Type); + return Results.Ok(); + } + + logger.LogInformation("Processing webhook {EventId} {Type}", stripeEvent.Id, stripeEvent.Type); + + try + { + switch (stripeEvent.Type) + { + case "checkout.session.completed": + await HandleCheckoutCompleted(stripeEvent, subscriptions, users, logger, ct); + break; + + case "invoice.paid": + await HandleInvoicePaid(stripeEvent, subscriptions, users, logger, ct); + break; + + case "invoice.payment_failed": + await HandleInvoicePaymentFailed(stripeEvent, subscriptions, logger, ct); + break; + + case "customer.subscription.updated": + await HandleSubscriptionUpdated(stripeEvent, subscriptions, logger, ct); + break; + + case "customer.subscription.deleted": + await HandleSubscriptionDeleted(stripeEvent, subscriptions, users, logger, ct); + break; + + default: + logger.LogInformation("Unhandled event type: {Type}", stripeEvent.Type); + break; + } + } + catch (Exception ex) + { + // Return 200 to Stripe to avoid retries for permanent failures. + // Log and investigate manually. + logger.LogError(ex, "Error processing webhook {EventId} {Type}", stripeEvent.Id, stripeEvent.Type); + } + + return Results.Ok(); + }) + .WithName("StripeWebhook") + .ExcludeFromDescription(); // hide from OpenAPI + } + + // ── checkout.session.completed ──────────────────────────────────────────── + // Fires on first successful payment. Metadata contains user_id + plan. + private static async Task HandleCheckoutCompleted( + Event stripeEvent, SubscriptionRepository subscriptions, + UserRepository users, ILogger logger, CancellationToken ct) + { + if (stripeEvent.Data.Object is not Session session) return; + + var userId = session.Metadata?.GetValueOrDefault("user_id"); + var plan = session.Metadata?.GetValueOrDefault("plan"); + + if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(plan)) + { + logger.LogWarning("checkout.session.completed missing metadata user_id/plan — session {Id}", session.Id); + return; + } + + var stripeSubId = session.SubscriptionId; + var stripeCustomId = session.CustomerId; + + if (string.IsNullOrEmpty(stripeSubId)) + { + logger.LogWarning("checkout.session.completed has no SubscriptionId — session {Id}", session.Id); + return; + } + + // Upsert subscription record + var sub = new NaluSubscription + { + UserId = userId, + StripeSubscriptionId = stripeSubId, + StripeCustomerId = stripeCustomId ?? "", + Plan = plan, + Status = "active", + CurrentPeriodStart = session.Created, // approximate — will be corrected by invoice.paid + CurrentPeriodEnd = session.Created.AddMonths(1), + }; + await subscriptions.UpsertAsync(sub, ct); + + // Link Stripe customer to user + upgrade plan + if (!string.IsNullOrEmpty(stripeCustomId)) + await users.SetStripeCustomerAsync(userId, stripeCustomId, ct); + + await users.UpdatePlanAsync(userId, plan, ct); + + logger.LogInformation("Checkout completed: user {UserId} upgraded to {Plan}", userId, plan); + } + + // ── invoice.paid ────────────────────────────────────────────────────────── + // Fires on every successful billing cycle (including first payment). + // First-cycle invoice.paid fires after checkout.session.completed, + // so idempotency protects the user plan from being double-written. + private static async Task HandleInvoicePaid( + Event stripeEvent, SubscriptionRepository subscriptions, + UserRepository users, ILogger logger, CancellationToken ct) + { + if (stripeEvent.Data.Object is not Invoice invoice) return; + + var stripeSubId = invoice.SubscriptionId; + if (string.IsNullOrEmpty(stripeSubId)) return; + + var sub = await subscriptions.FindByStripeIdAsync(stripeSubId, ct); + if (sub == null) + { + logger.LogWarning("invoice.paid: no subscription found for {SubId}", stripeSubId); + return; + } + + sub.Status = "active"; + sub.CurrentPeriodStart = invoice.PeriodStart; + sub.CurrentPeriodEnd = invoice.PeriodEnd; + await subscriptions.UpsertAsync(sub, ct); + + // Re-apply plan in case it was downgraded by a failed payment + await users.UpdatePlanAsync(sub.UserId, sub.Plan, ct); + + logger.LogInformation("Invoice paid: subscription {SubId} renewed for user {UserId}", stripeSubId, sub.UserId); + } + + // ── invoice.payment_failed ──────────────────────────────────────────────── + // Stripe will retry automatically. Mark as past_due but keep plan active + // until customer.subscription.deleted fires (after all retries exhausted). + private static async Task HandleInvoicePaymentFailed( + Event stripeEvent, SubscriptionRepository subscriptions, + ILogger logger, CancellationToken ct) + { + if (stripeEvent.Data.Object is not Invoice invoice) return; + + var stripeSubId = invoice.SubscriptionId; + if (string.IsNullOrEmpty(stripeSubId)) return; + + await subscriptions.UpdateStatusAsync(stripeSubId, "past_due", ct); + + logger.LogWarning("Invoice payment failed: subscription {SubId} marked past_due", stripeSubId); + } + + // ── customer.subscription.updated ──────────────────────────────────────── + // Plan upgrades/downgrades via Stripe portal, or CancelAtPeriodEnd toggled. + private static async Task HandleSubscriptionUpdated( + Event stripeEvent, SubscriptionRepository subscriptions, + ILogger logger, CancellationToken ct) + { + if (stripeEvent.Data.Object is not Subscription stripeSub) return; + + var sub = await subscriptions.FindByStripeIdAsync(stripeSub.Id, ct); + if (sub == null) + { + logger.LogWarning("subscription.updated: no local record for {SubId}", stripeSub.Id); + return; + } + + sub.Status = stripeSub.Status; + sub.CancelAtPeriodEnd = stripeSub.CancelAtPeriodEnd; + sub.CurrentPeriodStart = stripeSub.CurrentPeriodStart; + sub.CurrentPeriodEnd = stripeSub.CurrentPeriodEnd; + await subscriptions.UpsertAsync(sub, ct); + + logger.LogInformation("Subscription updated: {SubId} status={Status} cancelAtEnd={Cancel}", + stripeSub.Id, stripeSub.Status, stripeSub.CancelAtPeriodEnd); + } + + // ── customer.subscription.deleted ──────────────────────────────────────── + // All retries exhausted, or user cancelled immediately. + private static async Task HandleSubscriptionDeleted( + Event stripeEvent, SubscriptionRepository subscriptions, + UserRepository users, ILogger logger, CancellationToken ct) + { + if (stripeEvent.Data.Object is not Subscription stripeSub) return; + + var sub = await subscriptions.FindByStripeIdAsync(stripeSub.Id, ct); + if (sub == null) + { + logger.LogWarning("subscription.deleted: no local record for {SubId}", stripeSub.Id); + return; + } + + await subscriptions.UpdateStatusAsync(stripeSub.Id, "canceled", ct); + await users.UpdatePlanAsync(sub.UserId, "free", ct); + + logger.LogInformation("Subscription deleted: user {UserId} downgraded to free", sub.UserId); + } +} diff --git a/src/Nalu.Web/Infrastructure/HangfireDashboardAuth.cs b/src/Nalu.Web/Infrastructure/HangfireDashboardAuth.cs new file mode 100644 index 0000000..98631dd --- /dev/null +++ b/src/Nalu.Web/Infrastructure/HangfireDashboardAuth.cs @@ -0,0 +1,27 @@ +using Hangfire; +using Hangfire.Dashboard; + +namespace Nalu.Web.Infrastructure; + +/// +/// Allows Hangfire Dashboard access only from localhost or users in the Admin role. +/// +public sealed class HangfireDashboardAuth : IDashboardAuthorizationFilter +{ + public bool Authorize(DashboardContext context) + { + var http = context.GetHttpContext(); + + // Always allow localhost (dev / ops access) + if (http.Connection.RemoteIpAddress is { } ip && + (System.Net.IPAddress.IsLoopback(ip) || + ip.Equals(http.Connection.LocalIpAddress))) + { + return true; + } + + // Allow authenticated Admin users in production + return http.User.Identity?.IsAuthenticated == true + && http.User.IsInRole("Admin"); + } +} diff --git a/src/Nalu.Web/Models/ExtractionRequest.cs b/src/Nalu.Web/Models/ExtractionRequest.cs index 8f3667c..16bf4ef 100644 --- a/src/Nalu.Web/Models/ExtractionRequest.cs +++ b/src/Nalu.Web/Models/ExtractionRequest.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; namespace Nalu.Web.Models; @@ -5,12 +6,15 @@ namespace Nalu.Web.Models; public record ExtractionRequest { [JsonPropertyName("agent_input")] + [MaxLength(600)] public required string AgentInput { get; init; } [JsonPropertyName("user_input")] + [MaxLength(1000)] public required string UserInput { get; init; } [JsonPropertyName("agent_context")] + [MaxLength(2000)] public string? AgentContext { get; init; } [JsonPropertyName("language")] diff --git a/src/Nalu.Web/Models/ExtractionResponse.cs b/src/Nalu.Web/Models/ExtractionResponse.cs index 53ce6f2..6257cd6 100644 --- a/src/Nalu.Web/Models/ExtractionResponse.cs +++ b/src/Nalu.Web/Models/ExtractionResponse.cs @@ -26,4 +26,8 @@ public record ExtractionResponse /// "scalar" = plain string value | "object" = JSON-as-string, parse before use | null = no value (obtained: false) [JsonPropertyName("value_format")] public string? ValueFormat { get; init; } + + /// Opaque engine code — internal use only, do not document publicly. + [JsonPropertyName("engine")] + public string? Engine { get; init; } } diff --git a/src/Nalu.Web/Models/ValidatorDefinition.cs b/src/Nalu.Web/Models/ValidatorDefinition.cs index b3ee4ff..4005993 100644 --- a/src/Nalu.Web/Models/ValidatorDefinition.cs +++ b/src/Nalu.Web/Models/ValidatorDefinition.cs @@ -25,6 +25,10 @@ public class ValidatorDefinition public ListPostProcessors { get; set; } = []; public List Enrichers { get; set; } = []; + // When true: after a deterministic accept, pipeline queries nomes_br. + // All tokens found → certain=true. Any token missing → falls through to LLM. + public bool NameLookup { get; set; } + // Suggestions — flat (default) and localized (keyed by locale e.g. "pt-BR") public Dictionary Suggestions { get; set; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary > LocalizedSuggestions { get; set; } = new(StringComparer.OrdinalIgnoreCase); diff --git a/src/Nalu.Web/Nalu.Web.csproj b/src/Nalu.Web/Nalu.Web.csproj index b0e76b7..16f76d9 100644 --- a/src/Nalu.Web/Nalu.Web.csproj +++ b/src/Nalu.Web/Nalu.Web.csproj @@ -18,8 +18,14 @@ - + + + + + + + diff --git a/src/Nalu.Web/Pages/Casos/Parcelas48x.cshtml b/src/Nalu.Web/Pages/Casos/Parcelas48x.cshtml index e930ce7..cb85355 100644 --- a/src/Nalu.Web/Pages/Casos/Parcelas48x.cshtml +++ b/src/Nalu.Web/Pages/Casos/Parcelas48x.cshtml @@ -78,10 +78,10 @@ -diff --git a/src/Nalu.Web/Pages/Docs/Fluxos.cshtml b/src/Nalu.Web/Pages/Docs/Fluxos.cshtml index a596a47..7578299 100644 --- a/src/Nalu.Web/Pages/Docs/Fluxos.cshtml +++ b/src/Nalu.Web/Pages/Docs/Fluxos.cshtml @@ -1,8 +1,8 @@ @page "/docs/fluxos" @model Nalu.Web.Pages.Docs.FluxosModel @{ - ViewData["Title"] = "Fluxos — NALU AI Docs"; - ViewData["Description"] = "Fluxos de integração NALU AI com n8n, Make, chatbots e máquinas de estado."; + ViewData["Title"] = "Fluxos — NaLU AI Docs"; + ViewData["Description"] = "Fluxos de integração NaLU AI com n8n, Make, chatbots e máquinas de estado."; }Como o NALU AI resolve com validate_reply
+Como o NaLU AI resolve com validate_reply
@@ -107,7 +107,7 @@API RESPONSE — validate_reply{ @@ -131,7 +131,7 @@Código de integração
cURL-curl https://api.naluai.com/v1/extract/reply \ +diff --git a/src/Nalu.Web/Pages/Docs/ApiReference.cshtml b/src/Nalu.Web/Pages/Docs/ApiReference.cshtml index e354166..462f2e3 100644 --- a/src/Nalu.Web/Pages/Docs/ApiReference.cshtml +++ b/src/Nalu.Web/Pages/Docs/ApiReference.cshtml @@ -1,8 +1,8 @@ @page "/docs/api-reference" @model Nalu.Web.Pages.Docs.ApiReferenceModel @{ - ViewData["Title"] = "API Reference — NALU AI Docs"; - ViewData["Description"] = "Referência completa de todos os endpoints NALU AI. Parâmetros, respostas e exemplos."; + ViewData["Title"] = "API Reference — NaLU AI Docs"; + ViewData["Description"] = "Referência completa de todos os endpoints NaLU AI. Parâmetros, respostas e exemplos."; }curl https://api.naluai.dev/v1/extract/reply \ -H "Authorization: Bearer SEU_TOKEN" \ -H "Content-Type: application/json" \ -d '{ @@ -142,7 +142,7 @@JavaScript (n8n / Make)const { reply_type, extracted_value, value_type } = - await $http.post('https://api.naluai.com/v1/extract/reply', { + await $http.post('https://api.naluai.dev/v1/extract/reply', { agent_message: $node['Agent'].json.message, user_reply: $node['User'].json.reply, language: 'pt-BR' diff --git a/src/Nalu.Web/Pages/Checkout.cshtml b/src/Nalu.Web/Pages/Checkout.cshtml index 6193920..1768298 100644 --- a/src/Nalu.Web/Pages/Checkout.cshtml +++ b/src/Nalu.Web/Pages/Checkout.cshtml @@ -12,6 +12,7 @@⏳Redirecionando para o pagamento…
@@ -11,7 +11,7 @@ Docs / API Reference API Reference
-Base URL:
+https://api.naluai.com/v1/extractBase URL:
https://api.naluai.dev/v1/extract@@ -85,7 +85,7 @@ "credits_used": 3000, "credits_limit": 3000, "reset_at": "2026-06-01T00:00:00Z", - "upgrade_url": "https://naluai.com/precos", + "upgrade_url": "https://naluai.dev/precos", "hint": "Upgrade para Starter por apenas R$ 0,0058 por validação." }# Exemplo: detectar contraproposta de parcelas -curl https://api.naluai.com/v1/extract/reply \ +curl https://api.naluai.dev/v1/extract/reply \ -H "Authorization: Bearer SUA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ diff --git a/src/Nalu.Web/Pages/Docs/Creditos.cshtml b/src/Nalu.Web/Pages/Docs/Creditos.cshtml index c878046..e672e61 100644 --- a/src/Nalu.Web/Pages/Docs/Creditos.cshtml +++ b/src/Nalu.Web/Pages/Docs/Creditos.cshtml @@ -1,8 +1,8 @@ @page "/docs/creditos" @model Nalu.Web.Pages.Docs.CreditosModel @{ - ViewData["Title"] = "Créditos — NALU AI Docs"; - ViewData["Description"] = "Como o sistema de créditos NALU AI funciona, custo por validador e limites de plano."; + ViewData["Title"] = "Créditos — NaLU AI Docs"; + ViewData["Description"] = "Como o sistema de créditos NaLU AI funciona, custo por validador e limites de plano."; }diff --git a/src/Nalu.Web/Pages/Docs/Erros.cshtml b/src/Nalu.Web/Pages/Docs/Erros.cshtml index a7ff58d..8adbcb7 100644 --- a/src/Nalu.Web/Pages/Docs/Erros.cshtml +++ b/src/Nalu.Web/Pages/Docs/Erros.cshtml @@ -1,8 +1,8 @@ @page "/docs/erros" @model Nalu.Web.Pages.Docs.ErrosModel @{ - ViewData["Title"] = "Erros — NALU AI Docs"; - ViewData["Description"] = "Códigos HTTP, payloads de erro e como lidar com rate limit e falhas de serviço na NALU AI."; + ViewData["Title"] = "Erros — NaLU AI Docs"; + ViewData["Description"] = "Códigos HTTP, payloads de erro e como lidar com rate limit e falhas de serviço na NaLU AI."; } @@ -65,7 +65,7 @@ "credits_used": 100, "credits_limit": 100, "reset_at": "2026-05-11T00:00:00Z", - "upgrade_url": "https://naluai.com/precos", + "upgrade_url": "https://naluai.dev/precos", "hint": "Plano Starter: 15.000 créditos/mês sem limite diário. R$ 29/mês." } @@ -83,7 +83,7 @@ Custo: 5 créditos por análise de resposta
# n8n — nó "HTTP Request"
Method: POST
-URL: https://api.naluai.com/v1/extract/cpf
+URL: https://api.naluai.dev/v1/extract/cpf
Headers:
Authorization: Bearer {{ $env.NALU_API_KEY }}
Content-Type: application/json
@@ -120,7 +120,7 @@ loop:
if retries >= MAX_RETRIES:
→ escalar para humano ou abandonar coleta
- # Usar sugestão do NALU como próxima mensagem do agente
+ # Usar sugestão do NaLU como próxima mensagem do agente
proxima_mensagem = response.suggestion_to_agent
coleta_atual = aguardar_resposta_usuario(proxima_mensagem)
Tudo que você precisa para integrar o NALU AI ao seu chatbot.
+Tudo que você precisa para integrar o NaLU AI ao seu chatbot.
curl https://api.naluai.com/v1/extract/cpf \
+ curl https://api.naluai.dev/v1/extract/cpf \
-H "Authorization: Bearer SUA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
diff --git a/src/Nalu.Web/Pages/Docs/Mcp.cshtml b/src/Nalu.Web/Pages/Docs/Mcp.cshtml
index 3405717..ecb4c1a 100644
--- a/src/Nalu.Web/Pages/Docs/Mcp.cshtml
+++ b/src/Nalu.Web/Pages/Docs/Mcp.cshtml
@@ -1,8 +1,8 @@
@page "/docs/mcp"
@model Nalu.Web.Pages.Docs.McpModel
@{
- ViewData["Title"] = "MCP Server — NALU AI Docs";
- ViewData["Description"] = "Integre NALU AI com Claude Code, Cursor e qualquer cliente MCP via JSON-RPC 2.0.";
+ ViewData["Title"] = "MCP Server — NaLU AI Docs";
+ ViewData["Description"] = "Integre NaLU AI com Claude Desktop, Claude Code e qualquer cliente MCP via stdio.";
}
@@ -11,7 +11,7 @@
Docs / MCP Server
Use os validadores NALU como ferramentas nativas no Claude Code, Cursor e qualquer cliente MCP.
+Use os validadores NaLU como ferramentas nativas no Claude Desktop, Claude Code e qualquer cliente MCP.
@@ -23,20 +23,46 @@
Model Context Protocol (MCP) é um protocolo aberto que permite agentes de IA chamarem ferramentas externas de forma padronizada.
- O NALU AI expõe todos os validadores como ferramentas MCP via JSON-RPC 2.0 sobre stdio ou HTTP/SSE.
+ O servidor MCP da NaLU AI roda localmente via stdio e chama a API REST da NaLU — sem expor sua chave via rede.
Adicione ao seu ~/.claude/settings.json:
Clone ou baixe o servidor MCP e instale as dependências:
+# Baixar o servidor +git clone https://git.naluai.dev/nalu-mcp.git +cd nalu-mcp + +# Instalar dependências +npm install+
+ Edite %APPDATA%\Claude\claude_desktop_config.json (Windows)
+ ou ~/Library/Application Support/Claude/claude_desktop_config.json (macOS):
+
{
"mcpServers": {
"nalu": {
- "command": "npx",
- "args": ["-y", "@@naluai/mcp-server"],
+ "command": "node",
+ "args": ["/caminho/para/nalu-mcp/index.mjs"],
"env": {
"NALU_API_KEY": "SUA_API_KEY"
}
@@ -44,66 +70,55 @@
}
}
Ou via HTTP (se preferir não usar npx):
-{
- "mcpServers": {
- "nalu": {
- "url": "https://api.naluai.com/mcp",
- "headers": {
- "Authorization": "Bearer SUA_API_KEY"
- }
- }
- }
-}
- Reinicie o Claude Desktop após salvar. Os validadores aparecem automaticamente como ferramentas disponíveis.
Acesse Settings → MCP → Add Server e adicione:
+Name: NALU AI -URL: https://api.naluai.com/mcp -Headers: - Authorization: Bearer SUA_API_KEY+
claude mcp add nalu \ + --command node \ + --args "/caminho/para/nalu-mcp/index.mjs" \ + --env NALU_API_KEY=SUA_API_KEY
Após conectar, o agente de IA enxerga estas ferramentas:
+Após conectar, o agente enxerga estas ferramentas:
| Ferramenta | Descrição | +Créditos |
|---|---|---|
| @t.Item1 | @t.Item2 | +@t.Item3 cr |
| Parâmetro | +Tipo | +Descrição | +
|---|---|---|
| agent_input | string * | Mensagem do agente |
| user_input | string * | Resposta do usuário |
| language | string | Idioma (padrão: pt-BR) |
analyze_reply usa agent_message e user_reply no lugar.
Prompt para o Claude:
-"O usuário disse 'meu cpf é 111.444.777-35'. Use validate_cpf para extrair e validar."
+"O usuário disse 'meu cpf é 111.444.777-35'. Use extract_cpf para extrair e validar."
Claude chama automaticamente:
-validate_cpf({
+ extract_cpf({
"agent_input": "Qual o seu CPF?",
"user_input": "meu cpf é 111.444.777-35"
})
+ Retorno:
+ {
+ "obtained": true,
+ "extracted_value": "111.444.777-35",
+ "confidence": "high",
+ "certain": true
+}
nalu-XXXXXXXXXX.
+ Uma conta NaLU AI com uma API Key — crie grátis aqui. A key fica no seu Painel, formato nalu-XXXXXXXXXX.
- O NALU é uma API REST. No N8N, qualquer API REST é chamada com o nó + O NaLU é uma API REST. No N8N, qualquer API REST é chamada com o nó HTTP Request. Você manda o que o usuário digitou, - o NALU devolve o dado extraído e limpo — ou uma sugestão de como perguntar de novo. + o NaLU devolve o dado extraído e limpo — ou uma sugestão de como perguntar de novo.
Troque cpf pelo validador que precisar: cep, name, email, etc.
[ Webhook — mensagem do WhatsApp ]
↓
-[ HTTP Request → NALU /extract/cpf ]
+[ HTTP Request → NaLU /extract/cpf ]
body: { user_input: mensagem do usuário }
↓
[ IF: obtained === true ]
@@ -230,19 +230,19 @@
URLs de referência rápida
-POST https://api.naluai.com/v1/extract/cpf
-POST https://api.naluai.com/v1/extract/cep
-POST https://api.naluai.com/v1/extract/cnpj
-POST https://api.naluai.com/v1/extract/email
-POST https://api.naluai.com/v1/extract/phone
-POST https://api.naluai.com/v1/extract/name
-POST https://api.naluai.com/v1/extract/yes-no
-POST https://api.naluai.com/v1/extract/birthdate
-POST https://api.naluai.com/v1/extract/handoff
-POST https://api.naluai.com/v1/extract/cancel-intent
-POST https://api.naluai.com/v1/extract/company-name
-POST https://api.naluai.com/v1/extract/plate-br
-POST https://api.naluai.com/v1/extract/reply ← usa agent_message + user_reply
+POST https://api.naluai.dev/v1/extract/cpf
+POST https://api.naluai.dev/v1/extract/cep
+POST https://api.naluai.dev/v1/extract/cnpj
+POST https://api.naluai.dev/v1/extract/email
+POST https://api.naluai.dev/v1/extract/phone
+POST https://api.naluai.dev/v1/extract/name
+POST https://api.naluai.dev/v1/extract/yes-no
+POST https://api.naluai.dev/v1/extract/birthdate
+POST https://api.naluai.dev/v1/extract/handoff
+POST https://api.naluai.dev/v1/extract/cancel-intent
+POST https://api.naluai.dev/v1/extract/company-name
+POST https://api.naluai.dev/v1/extract/plate-br
+POST https://api.naluai.dev/v1/extract/reply ← usa agent_message + user_reply
diff --git a/src/Nalu.Web/Pages/Docs/Quickstart.cshtml b/src/Nalu.Web/Pages/Docs/Quickstart.cshtml
index 4f79642..f5b0477 100644
--- a/src/Nalu.Web/Pages/Docs/Quickstart.cshtml
+++ b/src/Nalu.Web/Pages/Docs/Quickstart.cshtml
@@ -1,8 +1,8 @@
@page "/docs/quickstart"
@model Nalu.Web.Pages.Docs.QuickstartModel
@{
- ViewData["Title"] = "Quickstart — NALU AI Docs";
- ViewData["Description"] = "Primeira chamada ao NALU AI em menos de 2 minutos. cURL, JavaScript, Python e C#.";
+ ViewData["Title"] = "Quickstart — NaLU AI Docs";
+ ViewData["Description"] = "Primeira chamada ao NaLU AI em menos de 2 minutos. cURL, JavaScript, Python e C#.";
}
@@ -45,7 +45,7 @@
curl https://api.naluai.com/v1/extract/cpf \
+curl https://api.naluai.dev/v1/extract/cpf \
-H "Authorization: Bearer SUA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
@@ -56,7 +56,7 @@
Extração semântica em camadas. Resultado normalizado e validado.
- Com o validate_reply do NALU AI, o bot entende que "48" no contexto + Com o validate_reply do NaLU AI, o bot entende que "48" no contexto de uma oferta de parcelas é uma contraproposta — não um valor. Custo por análise: R$ 0,0097. Menos que o cafezinho.
@@ -201,12 +202,12 @@ Mais barato que perder a venda.
+
curl https://api.naluai.com/v1/extract/name \ +curl https://api.naluai.dev/v1/extract/name \ -H "Authorization: Bearer SEU_TOKEN" \ -H "Content-Type: application/json" \ -d '{ @@ -245,7 +246,7 @@ Mais barato que perder a venda.diff --git a/src/Nalu.Web/Pages/Legal/Privacidade.cshtml b/src/Nalu.Web/Pages/Legal/Privacidade.cshtml new file mode 100644 index 0000000..430aa6b --- /dev/null +++ b/src/Nalu.Web/Pages/Legal/Privacidade.cshtml @@ -0,0 +1,100 @@ +@page "/privacidade" +@model Nalu.Web.Pages.Legal.PrivacidadeModel +@{ + ViewData["Title"] = "Política de Privacidade — NaLU AI"; + ViewData["Description"] = "Política de privacidade da NaLU AI. Não armazenamos o conteúdo das conversas. Seus dados são usados apenas para processar as chamadas."; +} + ++ + +++Política de Privacidade
+Última atualização: @DateTime.UtcNow.ToString("dd 'de' MMMM 'de' yyyy", new System.Globalization.CultureInfo("pt-BR"))
++ diff --git a/src/Nalu.Web/Pages/Legal/Privacidade.cshtml.cs b/src/Nalu.Web/Pages/Legal/Privacidade.cshtml.cs new file mode 100644 index 0000000..f9c6e24 --- /dev/null +++ b/src/Nalu.Web/Pages/Legal/Privacidade.cshtml.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Legal; + +public class PrivacidadeModel : PageModel +{ + public void OnGet() { } +} diff --git a/src/Nalu.Web/Pages/Legal/Termos.cshtml b/src/Nalu.Web/Pages/Legal/Termos.cshtml new file mode 100644 index 0000000..abcfc66 --- /dev/null +++ b/src/Nalu.Web/Pages/Legal/Termos.cshtml @@ -0,0 +1,104 @@ +@page "/termos" +@model Nalu.Web.Pages.Legal.TermosModel +@{ + ViewData["Title"] = "Termos de Uso — NaLU AI"; + ViewData["Description"] = "Termos de uso da NaLU AI. Conheça as regras de uso da plataforma, limites de crédito e responsabilidades."; +} + ++ ++++ +Resumo direto++
+- ✓ Não armazenamos o conteúdo das conversas enviadas à API
+- ✓ Usamos modelos de IA configurados sem retenção de dados
+- ✓ Pagamentos são processados pelo Stripe — não tocamos nos dados do cartão
+- ✓ Você pode solicitar exclusão da conta a qualquer momento
+++ +1. O que coletamos
+Dados de conta (quando você se cadastra):
++
+- Nome e endereço de e-mail (via OAuth Google, GitHub ou Microsoft)
+- Foto de perfil fornecida pelo provedor OAuth
+- Plano atual e histórico de uso de créditos
+Dados técnicos de uso:
++
+- Quantidade de chamadas à API por chave e por período
+- Créditos consumidos por validador
+- Endereço IP para rate limiting e proteção contra abuso
+- Logs de erro (sem conteúdo de conversa)
+++ +2. O que não coletamos
+++❌ Não armazenamos os textos de
+agent_input,user_inputou qualquer outro campo de diálogo enviado às chamadas de validação.❌ Não registramos o conteúdo das conversas dos seus usuários finais.
+❌ Não usamos os dados enviados para treinar modelos ou melhorar serviços além do processamento imediato da chamada.
+Os diálogos enviados à API são processados em memória e descartados imediatamente após a resposta.
+++ +3. Modelos de IA e infraestrutura
+A NaLU AI utiliza provedores de modelos de linguagem de terceiros para processamento semântico. Todos os provedores são contratados com retenção de dados desabilitada — os dados enviados não são armazenados, usados para treinamento nem compartilhados pelo provedor.
+Os dados enviados a esses provedores são exclusivamente o conteúdo do diálogo necessário para a validação. Nenhum dado de identificação do seu usuário final é transmitido.
+++ +4. Pagamentos
+Pagamentos são processados pelo Stripe, certificado PCI DSS nível 1. A NaLU AI não armazena, processa nem transmite dados de cartão de crédito. Ao realizar uma assinatura, você é redirecionado ao ambiente seguro do Stripe.
+++ +5. Login social (OAuth)
+O login via Google, GitHub ou Microsoft fornece apenas nome e e-mail. Não solicitamos permissões adicionais como acesso a e-mails, calendário ou outros dados da conta.
+++ +6. Cookies e armazenamento local
++
+- Cookie de sessão — necessário para manter o login ativo. Expirado ao fechar o browser ou após inatividade.
+- Sem cookies de rastreamento — não usamos Google Analytics, Meta Pixel ou qualquer ferramenta de rastreamento de comportamento.
+++ +7. Retenção e exclusão de dados
+Dados de conta são mantidos enquanto a conta estiver ativa. Para solicitar exclusão:
+ +Após a solicitação, todos os dados de conta são removidos em até 30 dias. Logs técnicos anonimizados podem ser mantidos por até 90 dias por requisitos de segurança.
+++ + + +8. Compartilhamento de dados
+Não vendemos nem compartilhamos seus dados com terceiros para fins comerciais. Dados são compartilhados apenas com os prestadores de serviço descritos nesta política (Stripe, provedores de IA) estritamente para execução do serviço.
++ + +++Termos de Uso
+Última atualização: @DateTime.UtcNow.ToString("dd 'de' MMMM 'de' yyyy", new System.Globalization.CultureInfo("pt-BR"))
++ diff --git a/src/Nalu.Web/Pages/Legal/Termos.cshtml.cs b/src/Nalu.Web/Pages/Legal/Termos.cshtml.cs new file mode 100644 index 0000000..f22a05a --- /dev/null +++ b/src/Nalu.Web/Pages/Legal/Termos.cshtml.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Nalu.Web.Pages.Legal; + +public class TermosModel : PageModel +{ + public void OnGet() { } +} diff --git a/src/Nalu.Web/Pages/Login.cshtml b/src/Nalu.Web/Pages/Login.cshtml index 800478e..e876e9a 100644 --- a/src/Nalu.Web/Pages/Login.cshtml +++ b/src/Nalu.Web/Pages/Login.cshtml @@ -1,7 +1,7 @@ @page "/login" @model Nalu.Web.Pages.LoginModel @{ - ViewData["Title"] = "Entrar — NALU AI"; + 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"; } @@ -11,8 +11,8 @@+ ++++ +1. Aceitação dos termos
+Ao criar uma conta ou utilizar a API da NaLU AI, você concorda com estes Termos de Uso. Se você utiliza a NaLU AI em nome de uma empresa, declara ter autoridade para vincular a empresa a estes termos.
+++ +2. Descrição do serviço
+A NaLU AI fornece uma API de extração semântica e validação de dados a partir de diálogos entre agentes de IA e usuários finais. O serviço é oferecido por meio de planos com créditos mensais.
+++ +3. Uso aceitável
+Você concorda em não utilizar o serviço para:
++
+- Processar dados pessoais sensíveis sem base legal adequada (saúde, religião, orientação sexual etc.)
+- Contornar limites de rate ou crédito por meios técnicos ou múltiplas contas
+- Revender acesso à API sem acordo comercial expresso com a NaLU AI
+- Qualquer atividade ilegal ou que viole direitos de terceiros
+++ +4. Créditos e pagamentos
++
+- Créditos não utilizados no mês não acumulam para o próximo ciclo.
+- Planos pagos são cobrados mensalmente via Stripe. Cancelamentos são efetivados no próximo ciclo.
+- Não há reembolso de créditos parcialmente utilizados, exceto por falha técnica comprovada da NaLU AI.
+- Ao esgotar os créditos, as chamadas retornam HTTP 402. Não há cobrança adicional automática.
+++ +5. Cancelamento e direito de arrependimento
++
+- + Direito de arrependimento (CDC, art. 49): nos primeiros 7 dias corridos após a contratação, + o assinante pode solicitar cancelamento com reembolso integral. Basta enviar e-mail para + com o assunto "Cancelamento — arrependimento". O reembolso é processado + em até 5 dias úteis. +
+- + Após 7 dias: o cancelamento é efetivado ao fim do ciclo vigente. Os créditos + do mês atual permanecem disponíveis até a data de encerramento. Não há cobrança no ciclo seguinte. +
+- + Upgrade ou downgrade de plano pode ser solicitado a qualquer momento pelo Painel + → Gerenciar assinatura. Mudanças de plano são aplicadas no próximo ciclo de cobrança. +
+++ +5. Privacidade dos dados
+O conteúdo dos diálogos enviados à API não é armazenado. Consulte nossa Política de Privacidade para detalhes completos.
+++ +6. Disponibilidade e SLA
++
+- Plano Free e Starter: sem garantia formal de SLA.
+- Plano Pro: disponibilidade alvo de 99% mensal. Créditos compensatórios em caso de descumprimento.
+- Manutenções programadas são comunicadas com 48h de antecedência.
+++ +7. Propriedade intelectual
+A API, documentação e infraestrutura da NaLU AI são de propriedade exclusiva da NaLU AI. O uso do serviço não transfere qualquer direito de propriedade intelectual ao usuário. Os dados que você envia permanecem de sua propriedade.
+++ +8. Limitação de responsabilidade
+A NaLU AI não se responsabiliza por decisões de negócio tomadas com base nos resultados das validações. O serviço é fornecido como auxílio técnico — a validação final e o uso dos dados extraídos são de responsabilidade do contratante.
+++ + + +9. Alterações nos termos
+Reservamo-nos o direito de alterar estes termos a qualquer momento. Alterações relevantes serão comunicadas por e-mail com 15 dias de antecedência. O uso continuado após esse prazo implica aceitação das novas condições.
+- NALU - AI + NaLU + ai@@ -36,7 +36,7 @@ + class="hidden 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">Entre para acessar seu painel e API key
Quer priorizar algum? - Fale com a gente → + Fale com a gente →
diff --git a/src/Nalu.Web/Pages/Validadores/Index.cshtml.cs b/src/Nalu.Web/Pages/Validadores/Index.cshtml.cs index ff9652f..b25ed99 100644 --- a/src/Nalu.Web/Pages/Validadores/Index.cshtml.cs +++ b/src/Nalu.Web/Pages/Validadores/Index.cshtml.cs @@ -19,7 +19,8 @@ public class IndexModel : PageModel [ new("✉️", "validate_email", "Extrai email e corrige typos de domínio (gmail→gmail.com).", 3), new("🌍", "validate_postal_code", "Código postal internacional (não CEP).", 3), - new("🔤", "validate_full_name", "Extrai nome completo, ignora saudações e títulos.", 3), + new("🔤", "validate_name", "Extrai nome ou apelido. Primeiro nome sem sobrenome é aceito.", 3), + new("📝", "validate_full_name", "Extrai nome completo com sobrenome. Exige ao menos duas palavras.", 3), new("☑️", "validate_yes_no", "Detecta sim/não em qualquer idioma e forma indireta.", 3), new("🎂", "validate_birthdate", "Data de nascimento em qualquer formato. Detecta menores.", 3), new("🤝", "validate_handoff", "Detecta intenção de falar com humano. Classifica urgência.", 3), diff --git a/src/Nalu.Web/Pages/_Layout.cshtml b/src/Nalu.Web/Pages/_Layout.cshtml index 6246ab2..ed46111 100644 --- a/src/Nalu.Web/Pages/_Layout.cshtml +++ b/src/Nalu.Web/Pages/_Layout.cshtml @@ -1,10 +1,76 @@ - +@{ + // Detect language from URL — reliable regardless of ViewData render order + var _isEn = Context.Request.Path.StartsWithSegments("/en"); + var _lp = _isEn ? "/en" : ""; + + // Map current URL to the alternate language equivalent + var _cur = Context.Request.Path.Value ?? "/"; + // Known pages that have a real PT↔EN pair + var _knownEn = new HashSet