Files

1 line
3.0 MiB
Plaintext
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "brucewillism/consumoesperto", "files": {"backend/src/main/resources/application.properties": {"d9062b816b44ebdda9aa588727ca874a6a156a55": {"t": 1785745404.1380858, "n": 49475, "h": "e68892bf20a4bd06", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL}\nspring.datasource.username=${DATABASE_USERNAME}\nspring.datasource.password=${DATABASE_PASSWORD}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Patches idempotentes de schema legado (desligar quando Flyway for fonte da verdade)\nconsumoesperto.schema.autopatch.enabled=${SCHEMA_AUTOPATCH_ENABLED:true}\n\n# Projeção de fechamento do mês (forecast / J.A.R.V.I.S.)\nconsumoesperto.forecast.dia-liminar-anti-susto=15\nconsumoesperto.forecast.margem-variavel-pct=10\n# Tolerância de dedup de provisão: percentual (default 10%) + piso absoluto em R$\nconsumoesperto.provisao.tolerancia-dedup-pct=10\nconsumoesperto.provisao.tolerancia-dedup-piso=2.00\n\n# Simulação de compra — divisor = meses com despesa confirmada (mínimo configurável)\nconsumoesperto.simulacao.meses-janela=6\nconsumoesperto.simulacao.meses-minimo-divisor=1\n\n# Burn rate Sentinela — janela máxima em dias (divisor = dias reais na janela)\nconsumoesperto.projecao.burn-dias-amostra=31\n\n# Webhook Evolution — autenticação fail-closed (header customizado e/ou token na query)\n# Rollback emergencial: evolution.webhook.auth-required=false\nevolution.webhook.auth-required=${EVOLUTION_WEBHOOK_AUTH_REQUIRED:true}\nevolution.webhook.secret=${EVOLUTION_WEBHOOK_SECRET:${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}}\nevolution.webhook.header-name=${EVOLUTION_WEBHOOK_HEADER_NAME:X-ConsumoEsperto-Webhook-Secret}\nevolution.webhook.query-param=${EVOLUTION_WEBHOOK_QUERY_PARAM:token}\nevolution.webhook.accept-legacy-apikey-header=true\n# Alias legado (mantido para compatibilidade de docs antigas):\nevolution.webhook.apikey-required=${evolution.webhook.auth-required}\n\n# Rate limit IA por usuário e por número WhatsApp (Bucket4j in-memory — VPS única)\nconsumoesperto.ai.rate-limit.requests-per-minute=30\nconsumoesperto.ai.rate-limit.whatsapp-per-number-per-minute=20\nconsumoesperto.ai.media.max-pdf-bytes=15728640\nconsumoesperto.ai.media.max-audio-bytes=26214400\n\n# Dedup webhook — retenção em dias (job de expurgo diário)\nconsumoesperto.webhook.dedup.retention-days=30\n\n# Integridade de saldo — detecção diária; correção automática desligada por padrão\nconsumoesperto.saldo.integridade.auto-corrigir=false\nconsumoesperto.saldo.integridade.cron=0 30 3 * * *\n\n# Reparo de dados financeiros — fail-closed: false = só dry-run/relatório (nada é escrito)\nconsumoesperto.saldo.reparo.enabled=${SALDO_REPARO_ENABLED:false}\n\n# Audit trail de saldo (movimentacao_saldo_log) — retenção do expurgo diário\nconsumoesperto.saldo.audit.retention-days=${SALDO_AUDIT_RETENTION_DAYS:730}\n\n# Alertas operacionais: log ERROR sempre; webhook externo opcional (divergência de saldo, auth do webhook)\nconsumoesperto.alertas.webhook-enabled=${ALERTAS_WEBHOOK_ENABLED:false}\nconsumoesperto.alertas.webhook-url=${ALERTAS_WEBHOOK_URL:}\nconsumoesperto.alertas.cooldown-minutes=${ALERTAS_COOLDOWN_MINUTES:15}\nconsumoesperto.alertas.timeout-ms=${ALERTAS_TIMEOUT_MS:5000}\n\n# Memória semântica J.A.R.V.I.S. — higiene de escrita, RAG híbrido e ciclo de vida\nconsumoesperto.memoria.dedupe-similaridade-minima=${MEMORIA_DEDUPE_SIMILARIDADE:0.90}\nconsumoesperto.memoria.superacao-similaridade-minima=${MEMORIA_SUPERACAO_SIMILARIDADE:0.78}\nconsumoesperto.memoria.superacao-similaridade-sem-categoria=${MEMORIA_SUPERACAO_SIMILARIDADE_SEM_CATEGORIA:0.88}\nconsumoesperto.memoria.rag-limite-contexto=${MEMORIA_RAG_LIMITE_CONTEXTO:5}\nconsumoesperto.memoria.rag-meia-vida-dias=${MEMORIA_RAG_MEIA_VIDA_DIAS:90}\nconsumoesperto.memoria.habito-min-ocorrencias=${MEMORIA_HABITO_MIN_OCORRENCIAS:5}\nconsumoesperto.memoria.habito-min-dias-distintos=${MEMORIA_HABITO_MIN_DIAS_DISTINTOS:3}\nconsumoesperto.memoria.habito-janela-minutos-duplicata=${MEMORIA_HABITO_JANELA_DUPLICATA_MIN:30}\nconsumoesperto.memoria.decaimento-confianca-mensal=${MEMORIA_DECAIMENTO_MENSAL:0.05}\nconsumoesperto.memoria.confianca-piso=${MEMORIA_CONFIANCA_PISO:0.25}\nconsumoesperto.memoria.consolidacao-min-cluster=${MEMORIA_CONSOLIDACAO_MIN_CLUSTER:6}\nconsumoesperto.memoria.consolidacao-similaridade=${MEMORIA_CONSOLIDACAO_SIMILARIDADE:0.85}\nconsumoesperto.memoria.captura-automatica-enabled=${MEMORIA_CAPTURA_AUTOMATICA_ENABLED:true}\n\n# Amortização sazonal — taxa de empréstimo padrão e desconto de antecipação para parcelas sem juros\nconsumoesperto.amortizacao.taxa-juros-mensal-padrao=0.0199\nconsumoesperto.amortizacao.taxa-desconto-sem-juros=0.005\n\n# Benchmark BCB por tipo de operação (séries SGS + fallback % a.a.)\nconsumoesperto.market.bcb.serie.cartao=25438\nconsumoesperto.market.bcb.serie.pessoal=25463\nconsumoesperto.market.bcb.serie.imobiliario=20743\nconsumoesperto.market.bcb.serie.veiculo=20746\nconsumoesperto.market.fallback.taxa-cartao-aa=130\nconsumoesperto.market.fallback.taxa-pessoal-aa=55\nconsumoesperto.market.fallback.taxa-imobiliario-aa=12\nconsumoesperto.market.fallback.taxa-veiculo-aa=22\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:false}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:false}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:false}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:false}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:600000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:90000}\n# Sessão persistente: mantém ligado até o utilizador desligar explicitamente na app\nconsumoesperto.evolution.session.sticky=${EVOLUTION_SESSION_STICKY:true}\n# Watchdog: deteta sessão fantasma (open na API, Baileys morto) e reinicia instância + webhook\nconsumoesperto.evolution.watchdog.enabled=${EVOLUTION_WATCHDOG_ENABLED:true}\nconsumoesperto.evolution.watchdog.interval-ms=${EVOLUTION_WATCHDOG_INTERVAL_MS:300000}\n# Keepalive (4h): webhook + reconnect leve + presença unavailable — sem restart destrutivo\nconsumoesperto.evolution.keepalive.interval-ms=${EVOLUTION_KEEPALIVE_INTERVAL_MS:14400000}\n# Admin ops (health Evolution): header X-Admin-Api-Key\nconsumoesperto.admin.api-key=${ADMIN_API_KEY:}\nconsumoesperto.evolution.metrics.instabilidade.scan-ms=${EVOLUTION_METRICS_SCAN_MS:300000}\nconsumoesperto.evolution.metrics.instabilidade.desconexoes-dia=${EVOLUTION_METRICS_DESCONEXOES_DIA:3}\nconsumoesperto.evolution.metrics.instabilidade.reconexoes-dia=${EVOLUTION_METRICS_RECONEXOES_DIA:5}\nconsumoesperto.evolution.metrics.instabilidade.inatividade-min=${EVOLUTION_METRICS_INATIVIDADE_MIN:45}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# AI Router inteligente — fallback por tipo de tarefa (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama)\nconsumoesperto.ai.router.enabled=${AI_ROUTER_ENABLED:true}\nconsumoesperto.ai.router.ollama-emergency-fallback=${AI_ROUTER_OLLAMA_EMERGENCY:false}\nconsumoesperto.ai.router.groq.enabled=${AI_ROUTER_GROQ_ENABLED:true}\nconsumoesperto.ai.router.openai.enabled=${AI_ROUTER_OPENAI_ENABLED:true}\nconsumoesperto.ai.router.claude.enabled=${AI_ROUTER_CLAUDE_ENABLED:true}\nconsumoesperto.ai.router.gemini.enabled=${AI_ROUTER_GEMINI_ENABLED:true}\nconsumoesperto.ai.router.deepseek.enabled=${AI_ROUTER_DEEPSEEK_ENABLED:true}\nconsumoesperto.ai.router.ollama.enabled=${AI_ROUTER_OLLAMA_ENABLED:false}\nconsumoesperto.ai.router.trace-max-entries=${AI_ROUTER_TRACE_MAX:10000}\nconsumoesperto.ai.router.alerts.fallback-rate-max=${AI_ROUTER_ALERT_FALLBACK_MAX:0.15}\nconsumoesperto.ai.router.alerts.latency-increase-max=${AI_ROUTER_ALERT_LATENCY_INCREASE:0.50}\nconsumoesperto.ai.router.alerts.structured-invalid-max=${AI_ROUTER_ALERT_STRUCT_INVALID:0.05}\nconsumoesperto.ai.router.alerts.daily-cost-limit-usd=${AI_ROUTER_ALERT_DAILY_COST_USD:25}\nconsumoesperto.ai.router.alerts.min-samples=${AI_ROUTER_ALERT_MIN_SAMPLES:10}\nconsumoesperto.ai.router.alerts.cooldown-minutes=${AI_ROUTER_ALERT_COOLDOWN_MIN:30}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# Performance J.A.R.V.I.S. — cache, janela multi-turno, métricas, timeouts de parse\nconsumoesperto.jarvis.performance.contexto-cache-ttl-seconds=${JARVIS_CONTEXTO_CACHE_TTL_SECONDS:90}\nconsumoesperto.jarvis.performance.market-cache-ttl-seconds=${JARVIS_MARKET_CACHE_TTL_SECONDS:7200}\nconsumoesperto.jarvis.performance.conversa-janela-ttl-seconds=${JARVIS_CONVERSA_JANELA_TTL_SECONDS:3600}\nconsumoesperto.jarvis.performance.conversa-janela-max-trocas=${JARVIS_CONVERSA_JANELA_MAX_TROCAS:6}\nconsumoesperto.jarvis.performance.ack-long-operation-ms=${JARVIS_ACK_LONG_OPERATION_MS:3000}\nconsumoesperto.jarvis.performance.parse-read-timeout-ms=${JARVIS_PARSE_READ_TIMEOUT_MS:25000}\nconsumoesperto.jarvis.performance.metrics-sample-size=${JARVIS_METRICS_SAMPLE_SIZE:500}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=<defina-no-.env-local>\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "6fe8c536200fb030e1b79f565c4d892cc215c73e": {"t": 1785752792.7959104, "n": 49475, "h": "e68892bf20a4bd06", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL}\nspring.datasource.username=${DATABASE_USERNAME}\nspring.datasource.password=${DATABASE_PASSWORD}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Patches idempotentes de schema legado (desligar quando Flyway for fonte da verdade)\nconsumoesperto.schema.autopatch.enabled=${SCHEMA_AUTOPATCH_ENABLED:true}\n\n# Projeção de fechamento do mês (forecast / J.A.R.V.I.S.)\nconsumoesperto.forecast.dia-liminar-anti-susto=15\nconsumoesperto.forecast.margem-variavel-pct=10\n# Tolerância de dedup de provisão: percentual (default 10%) + piso absoluto em R$\nconsumoesperto.provisao.tolerancia-dedup-pct=10\nconsumoesperto.provisao.tolerancia-dedup-piso=2.00\n\n# Simulação de compra — divisor = meses com despesa confirmada (mínimo configurável)\nconsumoesperto.simulacao.meses-janela=6\nconsumoesperto.simulacao.meses-minimo-divisor=1\n\n# Burn rate Sentinela — janela máxima em dias (divisor = dias reais na janela)\nconsumoesperto.projecao.burn-dias-amostra=31\n\n# Webhook Evolution — autenticação fail-closed (header customizado e/ou token na query)\n# Rollback emergencial: evolution.webhook.auth-required=false\nevolution.webhook.auth-required=${EVOLUTION_WEBHOOK_AUTH_REQUIRED:true}\nevolution.webhook.secret=${EVOLUTION_WEBHOOK_SECRET:${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}}\nevolution.webhook.header-name=${EVOLUTION_WEBHOOK_HEADER_NAME:X-ConsumoEsperto-Webhook-Secret}\nevolution.webhook.query-param=${EVOLUTION_WEBHOOK_QUERY_PARAM:token}\nevolution.webhook.accept-legacy-apikey-header=true\n# Alias legado (mantido para compatibilidade de docs antigas):\nevolution.webhook.apikey-required=${evolution.webhook.auth-required}\n\n# Rate limit IA por usuário e por número WhatsApp (Bucket4j in-memory — VPS única)\nconsumoesperto.ai.rate-limit.requests-per-minute=30\nconsumoesperto.ai.rate-limit.whatsapp-per-number-per-minute=20\nconsumoesperto.ai.media.max-pdf-bytes=15728640\nconsumoesperto.ai.media.max-audio-bytes=26214400\n\n# Dedup webhook — retenção em dias (job de expurgo diário)\nconsumoesperto.webhook.dedup.retention-days=30\n\n# Integridade de saldo — detecção diária; correção automática desligada por padrão\nconsumoesperto.saldo.integridade.auto-corrigir=false\nconsumoesperto.saldo.integridade.cron=0 30 3 * * *\n\n# Reparo de dados financeiros — fail-closed: false = só dry-run/relatório (nada é escrito)\nconsumoesperto.saldo.reparo.enabled=${SALDO_REPARO_ENABLED:false}\n\n# Audit trail de saldo (movimentacao_saldo_log) — retenção do expurgo diário\nconsumoesperto.saldo.audit.retention-days=${SALDO_AUDIT_RETENTION_DAYS:730}\n\n# Alertas operacionais: log ERROR sempre; webhook externo opcional (divergência de saldo, auth do webhook)\nconsumoesperto.alertas.webhook-enabled=${ALERTAS_WEBHOOK_ENABLED:false}\nconsumoesperto.alertas.webhook-url=${ALERTAS_WEBHOOK_URL:}\nconsumoesperto.alertas.cooldown-minutes=${ALERTAS_COOLDOWN_MINUTES:15}\nconsumoesperto.alertas.timeout-ms=${ALERTAS_TIMEOUT_MS:5000}\n\n# Memória semântica J.A.R.V.I.S. — higiene de escrita, RAG híbrido e ciclo de vida\nconsumoesperto.memoria.dedupe-similaridade-minima=${MEMORIA_DEDUPE_SIMILARIDADE:0.90}\nconsumoesperto.memoria.superacao-similaridade-minima=${MEMORIA_SUPERACAO_SIMILARIDADE:0.78}\nconsumoesperto.memoria.superacao-similaridade-sem-categoria=${MEMORIA_SUPERACAO_SIMILARIDADE_SEM_CATEGORIA:0.88}\nconsumoesperto.memoria.rag-limite-contexto=${MEMORIA_RAG_LIMITE_CONTEXTO:5}\nconsumoesperto.memoria.rag-meia-vida-dias=${MEMORIA_RAG_MEIA_VIDA_DIAS:90}\nconsumoesperto.memoria.habito-min-ocorrencias=${MEMORIA_HABITO_MIN_OCORRENCIAS:5}\nconsumoesperto.memoria.habito-min-dias-distintos=${MEMORIA_HABITO_MIN_DIAS_DISTINTOS:3}\nconsumoesperto.memoria.habito-janela-minutos-duplicata=${MEMORIA_HABITO_JANELA_DUPLICATA_MIN:30}\nconsumoesperto.memoria.decaimento-confianca-mensal=${MEMORIA_DECAIMENTO_MENSAL:0.05}\nconsumoesperto.memoria.confianca-piso=${MEMORIA_CONFIANCA_PISO:0.25}\nconsumoesperto.memoria.consolidacao-min-cluster=${MEMORIA_CONSOLIDACAO_MIN_CLUSTER:6}\nconsumoesperto.memoria.consolidacao-similaridade=${MEMORIA_CONSOLIDACAO_SIMILARIDADE:0.85}\nconsumoesperto.memoria.captura-automatica-enabled=${MEMORIA_CAPTURA_AUTOMATICA_ENABLED:true}\n\n# Amortização sazonal — taxa de empréstimo padrão e desconto de antecipação para parcelas sem juros\nconsumoesperto.amortizacao.taxa-juros-mensal-padrao=0.0199\nconsumoesperto.amortizacao.taxa-desconto-sem-juros=0.005\n\n# Benchmark BCB por tipo de operação (séries SGS + fallback % a.a.)\nconsumoesperto.market.bcb.serie.cartao=25438\nconsumoesperto.market.bcb.serie.pessoal=25463\nconsumoesperto.market.bcb.serie.imobiliario=20743\nconsumoesperto.market.bcb.serie.veiculo=20746\nconsumoesperto.market.fallback.taxa-cartao-aa=130\nconsumoesperto.market.fallback.taxa-pessoal-aa=55\nconsumoesperto.market.fallback.taxa-imobiliario-aa=12\nconsumoesperto.market.fallback.taxa-veiculo-aa=22\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:false}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:false}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:false}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:false}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:600000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:90000}\n# Sessão persistente: mantém ligado até o utilizador desligar explicitamente na app\nconsumoesperto.evolution.session.sticky=${EVOLUTION_SESSION_STICKY:true}\n# Watchdog: deteta sessão fantasma (open na API, Baileys morto) e reinicia instância + webhook\nconsumoesperto.evolution.watchdog.enabled=${EVOLUTION_WATCHDOG_ENABLED:true}\nconsumoesperto.evolution.watchdog.interval-ms=${EVOLUTION_WATCHDOG_INTERVAL_MS:300000}\n# Keepalive (4h): webhook + reconnect leve + presença unavailable — sem restart destrutivo\nconsumoesperto.evolution.keepalive.interval-ms=${EVOLUTION_KEEPALIVE_INTERVAL_MS:14400000}\n# Admin ops (health Evolution): header X-Admin-Api-Key\nconsumoesperto.admin.api-key=${ADMIN_API_KEY:}\nconsumoesperto.evolution.metrics.instabilidade.scan-ms=${EVOLUTION_METRICS_SCAN_MS:300000}\nconsumoesperto.evolution.metrics.instabilidade.desconexoes-dia=${EVOLUTION_METRICS_DESCONEXOES_DIA:3}\nconsumoesperto.evolution.metrics.instabilidade.reconexoes-dia=${EVOLUTION_METRICS_RECONEXOES_DIA:5}\nconsumoesperto.evolution.metrics.instabilidade.inatividade-min=${EVOLUTION_METRICS_INATIVIDADE_MIN:45}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# AI Router inteligente — fallback por tipo de tarefa (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama)\nconsumoesperto.ai.router.enabled=${AI_ROUTER_ENABLED:true}\nconsumoesperto.ai.router.ollama-emergency-fallback=${AI_ROUTER_OLLAMA_EMERGENCY:false}\nconsumoesperto.ai.router.groq.enabled=${AI_ROUTER_GROQ_ENABLED:true}\nconsumoesperto.ai.router.openai.enabled=${AI_ROUTER_OPENAI_ENABLED:true}\nconsumoesperto.ai.router.claude.enabled=${AI_ROUTER_CLAUDE_ENABLED:true}\nconsumoesperto.ai.router.gemini.enabled=${AI_ROUTER_GEMINI_ENABLED:true}\nconsumoesperto.ai.router.deepseek.enabled=${AI_ROUTER_DEEPSEEK_ENABLED:true}\nconsumoesperto.ai.router.ollama.enabled=${AI_ROUTER_OLLAMA_ENABLED:false}\nconsumoesperto.ai.router.trace-max-entries=${AI_ROUTER_TRACE_MAX:10000}\nconsumoesperto.ai.router.alerts.fallback-rate-max=${AI_ROUTER_ALERT_FALLBACK_MAX:0.15}\nconsumoesperto.ai.router.alerts.latency-increase-max=${AI_ROUTER_ALERT_LATENCY_INCREASE:0.50}\nconsumoesperto.ai.router.alerts.structured-invalid-max=${AI_ROUTER_ALERT_STRUCT_INVALID:0.05}\nconsumoesperto.ai.router.alerts.daily-cost-limit-usd=${AI_ROUTER_ALERT_DAILY_COST_USD:25}\nconsumoesperto.ai.router.alerts.min-samples=${AI_ROUTER_ALERT_MIN_SAMPLES:10}\nconsumoesperto.ai.router.alerts.cooldown-minutes=${AI_ROUTER_ALERT_COOLDOWN_MIN:30}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# Performance J.A.R.V.I.S. — cache, janela multi-turno, métricas, timeouts de parse\nconsumoesperto.jarvis.performance.contexto-cache-ttl-seconds=${JARVIS_CONTEXTO_CACHE_TTL_SECONDS:90}\nconsumoesperto.jarvis.performance.market-cache-ttl-seconds=${JARVIS_MARKET_CACHE_TTL_SECONDS:7200}\nconsumoesperto.jarvis.performance.conversa-janela-ttl-seconds=${JARVIS_CONVERSA_JANELA_TTL_SECONDS:3600}\nconsumoesperto.jarvis.performance.conversa-janela-max-trocas=${JARVIS_CONVERSA_JANELA_MAX_TROCAS:6}\nconsumoesperto.jarvis.performance.ack-long-operation-ms=${JARVIS_ACK_LONG_OPERATION_MS:3000}\nconsumoesperto.jarvis.performance.parse-read-timeout-ms=${JARVIS_PARSE_READ_TIMEOUT_MS:25000}\nconsumoesperto.jarvis.performance.metrics-sample-size=${JARVIS_METRICS_SAMPLE_SIZE:500}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=<defina-no-.env-local>\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "069e98f7bc0ded42cd00e0935c0a4e9e033193e3": {"t": 1785752794.3676498, "n": 49471, "h": "b50217df181ce612", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL}\nspring.datasource.username=${DATABASE_USERNAME}\nspring.datasource.password=${DATABASE_PASSWORD}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Patches idempotentes de schema legado (desligar quando Flyway for fonte da verdade)\nconsumoesperto.schema.autopatch.enabled=${SCHEMA_AUTOPATCH_ENABLED:true}\n\n# Projeção de fechamento do mês (forecast / J.A.R.V.I.S.)\nconsumoesperto.forecast.dia-liminar-anti-susto=15\nconsumoesperto.forecast.margem-variavel-pct=10\n# Tolerância de dedup de provisão: percentual (default 10%) + piso absoluto em R$\nconsumoesperto.provisao.tolerancia-dedup-pct=10\nconsumoesperto.provisao.tolerancia-dedup-piso=2.00\n\n# Simulação de compra — divisor = meses com despesa confirmada (mínimo configurável)\nconsumoesperto.simulacao.meses-janela=6\nconsumoesperto.simulacao.meses-minimo-divisor=1\n\n# Burn rate Sentinela — janela máxima em dias (divisor = dias reais na janela)\nconsumoesperto.projecao.burn-dias-amostra=31\n\n# Webhook Evolution — autenticação fail-closed (header customizado e/ou token na query)\n# Rollback emergencial: evolution.webhook.auth-required=false\nevolution.webhook.auth-required=${EVOLUTION_WEBHOOK_AUTH_REQUIRED:true}\nevolution.webhook.secret=${EVOLUTION_WEBHOOK_SECRET:${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}}\nevolution.webhook.header-name=${EVOLUTION_WEBHOOK_HEADER_NAME:X-ConsumoEsperto-Webhook-Secret}\nevolution.webhook.query-param=${EVOLUTION_WEBHOOK_QUERY_PARAM:token}\nevolution.webhook.accept-legacy-apikey-header=true\n# Alias legado (mantido para compatibilidade de docs antigas):\nevolution.webhook.apikey-required=${evolution.webhook.auth-required}\n\n# Rate limit IA por usuário e por número WhatsApp (Bucket4j in-memory — VPS única)\nconsumoesperto.ai.rate-limit.requests-per-minute=30\nconsumoesperto.ai.rate-limit.whatsapp-per-number-per-minute=20\nconsumoesperto.ai.media.max-pdf-bytes=15728640\nconsumoesperto.ai.media.max-audio-bytes=26214400\n\n# Dedup webhook — retenção em dias (job de expurgo diário)\nconsumoesperto.webhook.dedup.retention-days=30\n\n# Integridade de saldo — detecção diária; correção automática desligada por padrão\nconsumoesperto.saldo.integridade.auto-corrigir=false\nconsumoesperto.saldo.integridade.cron=0 30 3 * * *\n\n# Reparo de dados financeiros — fail-closed: false = só dry-run/relatório (nada é escrito)\nconsumoesperto.saldo.reparo.enabled=${SALDO_REPARO_ENABLED:false}\n\n# Audit trail de saldo (movimentacao_saldo_log) — retenção do expurgo diário\nconsumoesperto.saldo.audit.retention-days=${SALDO_AUDIT_RETENTION_DAYS:730}\n\n# Alertas operacionais: log ERROR sempre; webhook externo opcional (divergência de saldo, auth do webhook)\nconsumoesperto.alertas.webhook-enabled=${ALERTAS_WEBHOOK_ENABLED:false}\nconsumoesperto.alertas.webhook-url=${ALERTAS_WEBHOOK_URL:}\nconsumoesperto.alertas.cooldown-minutes=${ALERTAS_COOLDOWN_MINUTES:15}\nconsumoesperto.alertas.timeout-ms=${ALERTAS_TIMEOUT_MS:5000}\n\n# Memória semântica J.A.R.V.I.S. — higiene de escrita, RAG híbrido e ciclo de vida\nconsumoesperto.memoria.dedupe-similaridade-minima=${MEMORIA_DEDUPE_SIMILARIDADE:0.90}\nconsumoesperto.memoria.superacao-similaridade-minima=${MEMORIA_SUPERACAO_SIMILARIDADE:0.78}\nconsumoesperto.memoria.superacao-similaridade-sem-categoria=${MEMORIA_SUPERACAO_SIMILARIDADE_SEM_CATEGORIA:0.88}\nconsumoesperto.memoria.rag-limite-contexto=${MEMORIA_RAG_LIMITE_CONTEXTO:5}\nconsumoesperto.memoria.rag-meia-vida-dias=${MEMORIA_RAG_MEIA_VIDA_DIAS:90}\nconsumoesperto.memoria.habito-min-ocorrencias=${MEMORIA_HABITO_MIN_OCORRENCIAS:5}\nconsumoesperto.memoria.habito-min-dias-distintos=${MEMORIA_HABITO_MIN_DIAS_DISTINTOS:3}\nconsumoesperto.memoria.habito-janela-minutos-duplicata=${MEMORIA_HABITO_JANELA_DUPLICATA_MIN:30}\nconsumoesperto.memoria.decaimento-confianca-mensal=${MEMORIA_DECAIMENTO_MENSAL:0.05}\nconsumoesperto.memoria.confianca-piso=${MEMORIA_CONFIANCA_PISO:0.25}\nconsumoesperto.memoria.consolidacao-min-cluster=${MEMORIA_CONSOLIDACAO_MIN_CLUSTER:6}\nconsumoesperto.memoria.consolidacao-similaridade=${MEMORIA_CONSOLIDACAO_SIMILARIDADE:0.85}\nconsumoesperto.memoria.captura-automatica-enabled=${MEMORIA_CAPTURA_AUTOMATICA_ENABLED:true}\n\n# Amortização sazonal — taxa de empréstimo padrão e desconto de antecipação para parcelas sem juros\nconsumoesperto.amortizacao.taxa-juros-mensal-padrao=0.0199\nconsumoesperto.amortizacao.taxa-desconto-sem-juros=0.005\n\n# Benchmark BCB por tipo de operação (séries SGS + fallback % a.a.)\nconsumoesperto.market.bcb.serie.cartao=25438\nconsumoesperto.market.bcb.serie.pessoal=25463\nconsumoesperto.market.bcb.serie.imobiliario=20743\nconsumoesperto.market.bcb.serie.veiculo=20746\nconsumoesperto.market.fallback.taxa-cartao-aa=130\nconsumoesperto.market.fallback.taxa-pessoal-aa=55\nconsumoesperto.market.fallback.taxa-imobiliario-aa=12\nconsumoesperto.market.fallback.taxa-veiculo-aa=22\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:false}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:false}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:false}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:false}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:600000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:90000}\n# Sessão persistente: mantém ligado até o utilizador desligar explicitamente na app\nconsumoesperto.evolution.session.sticky=${EVOLUTION_SESSION_STICKY:true}\n# Watchdog: deteta sessão fantasma (open na API, Baileys morto) e reinicia instância + webhook\nconsumoesperto.evolution.watchdog.enabled=${EVOLUTION_WATCHDOG_ENABLED:true}\nconsumoesperto.evolution.watchdog.interval-ms=${EVOLUTION_WATCHDOG_INTERVAL_MS:300000}\n# Keepalive (4h): webhook + reconnect leve + presença unavailable — sem restart destrutivo\nconsumoesperto.evolution.keepalive.interval-ms=${EVOLUTION_KEEPALIVE_INTERVAL_MS:14400000}\n# Admin ops (health Evolution): header X-Admin-Api-Key\nconsumoesperto.admin.api-key=${ADMIN_API_KEY:}\nconsumoesperto.evolution.metrics.instabilidade.scan-ms=${EVOLUTION_METRICS_SCAN_MS:300000}\nconsumoesperto.evolution.metrics.instabilidade.desconexoes-dia=${EVOLUTION_METRICS_DESCONEXOES_DIA:3}\nconsumoesperto.evolution.metrics.instabilidade.reconexoes-dia=${EVOLUTION_METRICS_RECONEXOES_DIA:5}\nconsumoesperto.evolution.metrics.instabilidade.inatividade-min=${EVOLUTION_METRICS_INATIVIDADE_MIN:45}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# AI Router inteligente — fallback por tipo de tarefa (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama)\nconsumoesperto.ai.router.enabled=${AI_ROUTER_ENABLED:true}\nconsumoesperto.ai.router.ollama-emergency-fallback=${AI_ROUTER_OLLAMA_EMERGENCY:false}\nconsumoesperto.ai.router.groq.enabled=${AI_ROUTER_GROQ_ENABLED:true}\nconsumoesperto.ai.router.openai.enabled=${AI_ROUTER_OPENAI_ENABLED:true}\nconsumoesperto.ai.router.claude.enabled=${AI_ROUTER_CLAUDE_ENABLED:true}\nconsumoesperto.ai.router.gemini.enabled=${AI_ROUTER_GEMINI_ENABLED:true}\nconsumoesperto.ai.router.deepseek.enabled=${AI_ROUTER_DEEPSEEK_ENABLED:true}\nconsumoesperto.ai.router.ollama.enabled=${AI_ROUTER_OLLAMA_ENABLED:false}\nconsumoesperto.ai.router.trace-max-entries=${AI_ROUTER_TRACE_MAX:10000}\nconsumoesperto.ai.router.alerts.fallback-rate-max=${AI_ROUTER_ALERT_FALLBACK_MAX:0.15}\nconsumoesperto.ai.router.alerts.latency-increase-max=${AI_ROUTER_ALERT_LATENCY_INCREASE:0.50}\nconsumoesperto.ai.router.alerts.structured-invalid-max=${AI_ROUTER_ALERT_STRUCT_INVALID:0.05}\nconsumoesperto.ai.router.alerts.daily-cost-limit-usd=${AI_ROUTER_ALERT_DAILY_COST_USD:25}\nconsumoesperto.ai.router.alerts.min-samples=${AI_ROUTER_ALERT_MIN_SAMPLES:10}\nconsumoesperto.ai.router.alerts.cooldown-minutes=${AI_ROUTER_ALERT_COOLDOWN_MIN:30}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# Performance J.A.R.V.I.S. — cache, janela multi-turno, métricas, timeouts de parse\nconsumoesperto.jarvis.performance.contexto-cache-ttl-seconds=${JARVIS_CONTEXTO_CACHE_TTL_SECONDS:90}\nconsumoesperto.jarvis.performance.market-cache-ttl-seconds=${JARVIS_MARKET_CACHE_TTL_SECONDS:7200}\nconsumoesperto.jarvis.performance.conversa-janela-ttl-seconds=${JARVIS_CONVERSA_JANELA_TTL_SECONDS:3600}\nconsumoesperto.jarvis.performance.conversa-janela-max-trocas=${JARVIS_CONVERSA_JANELA_MAX_TROCAS:6}\nconsumoesperto.jarvis.performance.ack-long-operation-ms=${JARVIS_ACK_LONG_OPERATION_MS:3000}\nconsumoesperto.jarvis.performance.parse-read-timeout-ms=${JARVIS_PARSE_READ_TIMEOUT_MS:25000}\nconsumoesperto.jarvis.performance.metrics-sample-size=${JARVIS_METRICS_SAMPLE_SIZE:500}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "53fe97ecde44cbe20206eb2526db0b7939d99e9b": {"t": 1785752795.8957965, "n": 49446, "h": "b4af1a833c86dcc0", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Projeção de fechamento do mês (forecast / J.A.R.V.I.S.)\nconsumoesperto.forecast.dia-liminar-anti-susto=15\nconsumoesperto.forecast.margem-variavel-pct=10\n# Tolerância de dedup de provisão: percentual (default 10%) + piso absoluto em R$\nconsumoesperto.provisao.tolerancia-dedup-pct=10\nconsumoesperto.provisao.tolerancia-dedup-piso=2.00\n\n# Simulação de compra — divisor = meses com despesa confirmada (mínimo configurável)\nconsumoesperto.simulacao.meses-janela=6\nconsumoesperto.simulacao.meses-minimo-divisor=1\n\n# Burn rate Sentinela — janela máxima em dias (divisor = dias reais na janela)\nconsumoesperto.projecao.burn-dias-amostra=31\n\n# Webhook Evolution — autenticação fail-closed (header customizado e/ou token na query)\n# Rollback emergencial: evolution.webhook.auth-required=false\nevolution.webhook.auth-required=${EVOLUTION_WEBHOOK_AUTH_REQUIRED:true}\nevolution.webhook.secret=${EVOLUTION_WEBHOOK_SECRET:${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}}\nevolution.webhook.header-name=${EVOLUTION_WEBHOOK_HEADER_NAME:X-ConsumoEsperto-Webhook-Secret}\nevolution.webhook.query-param=${EVOLUTION_WEBHOOK_QUERY_PARAM:token}\nevolution.webhook.accept-legacy-apikey-header=true\n# Alias legado (mantido para compatibilidade de docs antigas):\nevolution.webhook.apikey-required=${evolution.webhook.auth-required}\n\n# Rate limit IA por usuário e por número WhatsApp (Bucket4j in-memory — VPS única)\nconsumoesperto.ai.rate-limit.requests-per-minute=30\nconsumoesperto.ai.rate-limit.whatsapp-per-number-per-minute=20\nconsumoesperto.ai.media.max-pdf-bytes=15728640\nconsumoesperto.ai.media.max-audio-bytes=26214400\n\n# Dedup webhook — retenção em dias (job de expurgo diário)\nconsumoesperto.webhook.dedup.retention-days=30\n\n# Integridade de saldo — detecção diária; correção automática desligada por padrão\nconsumoesperto.saldo.integridade.auto-corrigir=false\nconsumoesperto.saldo.integridade.cron=0 30 3 * * *\n\n# Reparo de dados financeiros — fail-closed: false = só dry-run/relatório (nada é escrito)\nconsumoesperto.saldo.reparo.enabled=${SALDO_REPARO_ENABLED:false}\n\n# Audit trail de saldo (movimentacao_saldo_log) — retenção do expurgo diário\nconsumoesperto.saldo.audit.retention-days=${SALDO_AUDIT_RETENTION_DAYS:730}\n\n# Alertas operacionais: log ERROR sempre; webhook externo opcional (divergência de saldo, auth do webhook)\nconsumoesperto.alertas.webhook-enabled=${ALERTAS_WEBHOOK_ENABLED:false}\nconsumoesperto.alertas.webhook-url=${ALERTAS_WEBHOOK_URL:}\nconsumoesperto.alertas.cooldown-minutes=${ALERTAS_COOLDOWN_MINUTES:15}\nconsumoesperto.alertas.timeout-ms=${ALERTAS_TIMEOUT_MS:5000}\n\n# Memória semântica J.A.R.V.I.S. — higiene de escrita, RAG híbrido e ciclo de vida\nconsumoesperto.memoria.dedupe-similaridade-minima=${MEMORIA_DEDUPE_SIMILARIDADE:0.90}\nconsumoesperto.memoria.superacao-similaridade-minima=${MEMORIA_SUPERACAO_SIMILARIDADE:0.78}\nconsumoesperto.memoria.superacao-similaridade-sem-categoria=${MEMORIA_SUPERACAO_SIMILARIDADE_SEM_CATEGORIA:0.88}\nconsumoesperto.memoria.rag-limite-contexto=${MEMORIA_RAG_LIMITE_CONTEXTO:5}\nconsumoesperto.memoria.rag-meia-vida-dias=${MEMORIA_RAG_MEIA_VIDA_DIAS:90}\nconsumoesperto.memoria.habito-min-ocorrencias=${MEMORIA_HABITO_MIN_OCORRENCIAS:5}\nconsumoesperto.memoria.habito-min-dias-distintos=${MEMORIA_HABITO_MIN_DIAS_DISTINTOS:3}\nconsumoesperto.memoria.habito-janela-minutos-duplicata=${MEMORIA_HABITO_JANELA_DUPLICATA_MIN:30}\nconsumoesperto.memoria.decaimento-confianca-mensal=${MEMORIA_DECAIMENTO_MENSAL:0.05}\nconsumoesperto.memoria.confianca-piso=${MEMORIA_CONFIANCA_PISO:0.25}\nconsumoesperto.memoria.consolidacao-min-cluster=${MEMORIA_CONSOLIDACAO_MIN_CLUSTER:6}\nconsumoesperto.memoria.consolidacao-similaridade=${MEMORIA_CONSOLIDACAO_SIMILARIDADE:0.85}\nconsumoesperto.memoria.captura-automatica-enabled=${MEMORIA_CAPTURA_AUTOMATICA_ENABLED:true}\n\n# Amortização sazonal — taxa de empréstimo padrão e desconto de antecipação para parcelas sem juros\nconsumoesperto.amortizacao.taxa-juros-mensal-padrao=0.0199\nconsumoesperto.amortizacao.taxa-desconto-sem-juros=0.005\n\n# Benchmark BCB por tipo de operação (séries SGS + fallback % a.a.)\nconsumoesperto.market.bcb.serie.cartao=25438\nconsumoesperto.market.bcb.serie.pessoal=25463\nconsumoesperto.market.bcb.serie.imobiliario=20743\nconsumoesperto.market.bcb.serie.veiculo=20746\nconsumoesperto.market.fallback.taxa-cartao-aa=130\nconsumoesperto.market.fallback.taxa-pessoal-aa=55\nconsumoesperto.market.fallback.taxa-imobiliario-aa=12\nconsumoesperto.market.fallback.taxa-veiculo-aa=22\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:false}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:false}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:false}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:false}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:600000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:90000}\n# Sessão persistente: mantém ligado até o utilizador desligar explicitamente na app\nconsumoesperto.evolution.session.sticky=${EVOLUTION_SESSION_STICKY:true}\n# Watchdog: deteta sessão fantasma (open na API, Baileys morto) e reinicia instância + webhook\nconsumoesperto.evolution.watchdog.enabled=${EVOLUTION_WATCHDOG_ENABLED:true}\nconsumoesperto.evolution.watchdog.interval-ms=${EVOLUTION_WATCHDOG_INTERVAL_MS:300000}\n# Keepalive (4h): webhook + reconnect leve + presença unavailable — sem restart destrutivo\nconsumoesperto.evolution.keepalive.interval-ms=${EVOLUTION_KEEPALIVE_INTERVAL_MS:14400000}\n# Admin ops (health Evolution): header X-Admin-Api-Key\nconsumoesperto.admin.api-key=${ADMIN_API_KEY:}\nconsumoesperto.evolution.metrics.instabilidade.scan-ms=${EVOLUTION_METRICS_SCAN_MS:300000}\nconsumoesperto.evolution.metrics.instabilidade.desconexoes-dia=${EVOLUTION_METRICS_DESCONEXOES_DIA:3}\nconsumoesperto.evolution.metrics.instabilidade.reconexoes-dia=${EVOLUTION_METRICS_RECONEXOES_DIA:5}\nconsumoesperto.evolution.metrics.instabilidade.inatividade-min=${EVOLUTION_METRICS_INATIVIDADE_MIN:45}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# AI Router inteligente — fallback por tipo de tarefa (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama)\nconsumoesperto.ai.router.enabled=${AI_ROUTER_ENABLED:true}\nconsumoesperto.ai.router.ollama-emergency-fallback=${AI_ROUTER_OLLAMA_EMERGENCY:false}\nconsumoesperto.ai.router.groq.enabled=${AI_ROUTER_GROQ_ENABLED:true}\nconsumoesperto.ai.router.openai.enabled=${AI_ROUTER_OPENAI_ENABLED:true}\nconsumoesperto.ai.router.claude.enabled=${AI_ROUTER_CLAUDE_ENABLED:true}\nconsumoesperto.ai.router.gemini.enabled=${AI_ROUTER_GEMINI_ENABLED:true}\nconsumoesperto.ai.router.deepseek.enabled=${AI_ROUTER_DEEPSEEK_ENABLED:true}\nconsumoesperto.ai.router.ollama.enabled=${AI_ROUTER_OLLAMA_ENABLED:false}\nconsumoesperto.ai.router.trace-max-entries=${AI_ROUTER_TRACE_MAX:10000}\nconsumoesperto.ai.router.alerts.fallback-rate-max=${AI_ROUTER_ALERT_FALLBACK_MAX:0.15}\nconsumoesperto.ai.router.alerts.latency-increase-max=${AI_ROUTER_ALERT_LATENCY_INCREASE:0.50}\nconsumoesperto.ai.router.alerts.structured-invalid-max=${AI_ROUTER_ALERT_STRUCT_INVALID:0.05}\nconsumoesperto.ai.router.alerts.daily-cost-limit-usd=${AI_ROUTER_ALERT_DAILY_COST_USD:25}\nconsumoesperto.ai.router.alerts.min-samples=${AI_ROUTER_ALERT_MIN_SAMPLES:10}\nconsumoesperto.ai.router.alerts.cooldown-minutes=${AI_ROUTER_ALERT_COOLDOWN_MIN:30}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# Performance J.A.R.V.I.S. — cache, janela multi-turno, métricas, timeouts de parse\nconsumoesperto.jarvis.performance.contexto-cache-ttl-seconds=${JARVIS_CONTEXTO_CACHE_TTL_SECONDS:90}\nconsumoesperto.jarvis.performance.market-cache-ttl-seconds=${JARVIS_MARKET_CACHE_TTL_SECONDS:7200}\nconsumoesperto.jarvis.performance.conversa-janela-ttl-seconds=${JARVIS_CONVERSA_JANELA_TTL_SECONDS:3600}\nconsumoesperto.jarvis.performance.conversa-janela-max-trocas=${JARVIS_CONVERSA_JANELA_MAX_TROCAS:6}\nconsumoesperto.jarvis.performance.ack-long-operation-ms=${JARVIS_ACK_LONG_OPERATION_MS:3000}\nconsumoesperto.jarvis.performance.parse-read-timeout-ms=${JARVIS_PARSE_READ_TIMEOUT_MS:25000}\nconsumoesperto.jarvis.performance.metrics-sample-size=${JARVIS_METRICS_SAMPLE_SIZE:500}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "7e847a5800704bef1bcb26978e6c313777dd7874": {"t": 1785752798.3396437, "n": 48163, "h": "1412a49b17a24403", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Projeção de fechamento do mês (forecast / J.A.R.V.I.S.)\nconsumoesperto.forecast.dia-liminar-anti-susto=15\nconsumoesperto.forecast.margem-variavel-pct=10\n# Tolerância de dedup de provisão: percentual (default 10%) + piso absoluto em R$\nconsumoesperto.provisao.tolerancia-dedup-pct=10\nconsumoesperto.provisao.tolerancia-dedup-piso=2.00\n\n# Simulação de compra — divisor = meses com despesa confirmada (mínimo configurável)\nconsumoesperto.simulacao.meses-janela=6\nconsumoesperto.simulacao.meses-minimo-divisor=1\n\n# Burn rate Sentinela — janela máxima em dias (divisor = dias reais na janela)\nconsumoesperto.projecao.burn-dias-amostra=31\n\n# Webhook Evolution — autenticação fail-closed (header customizado e/ou token na query)\n# Rollback emergencial: evolution.webhook.auth-required=false\nevolution.webhook.auth-required=${EVOLUTION_WEBHOOK_AUTH_REQUIRED:true}\nevolution.webhook.secret=${EVOLUTION_WEBHOOK_SECRET:${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}}\nevolution.webhook.header-name=${EVOLUTION_WEBHOOK_HEADER_NAME:X-ConsumoEsperto-Webhook-Secret}\nevolution.webhook.query-param=${EVOLUTION_WEBHOOK_QUERY_PARAM:token}\nevolution.webhook.accept-legacy-apikey-header=true\n# Alias legado (mantido para compatibilidade de docs antigas):\nevolution.webhook.apikey-required=${evolution.webhook.auth-required}\n\n# Rate limit IA por usuário e por número WhatsApp (Bucket4j in-memory — VPS única)\nconsumoesperto.ai.rate-limit.requests-per-minute=30\nconsumoesperto.ai.rate-limit.whatsapp-per-number-per-minute=20\nconsumoesperto.ai.media.max-pdf-bytes=15728640\nconsumoesperto.ai.media.max-audio-bytes=26214400\n\n# Dedup webhook — retenção em dias (job de expurgo diário)\nconsumoesperto.webhook.dedup.retention-days=30\n\n# Integridade de saldo — detecção diária; correção automática desligada por padrão\nconsumoesperto.saldo.integridade.auto-corrigir=false\nconsumoesperto.saldo.integridade.cron=0 30 3 * * *\n\n# Reparo de dados financeiros — fail-closed: false = só dry-run/relatório (nada é escrito)\nconsumoesperto.saldo.reparo.enabled=${SALDO_REPARO_ENABLED:false}\n\n# Audit trail de saldo (movimentacao_saldo_log) — retenção do expurgo diário\nconsumoesperto.saldo.audit.retention-days=${SALDO_AUDIT_RETENTION_DAYS:730}\n\n# Alertas operacionais: log ERROR sempre; webhook externo opcional (divergência de saldo, auth do webhook)\nconsumoesperto.alertas.webhook-enabled=${ALERTAS_WEBHOOK_ENABLED:false}\nconsumoesperto.alertas.webhook-url=${ALERTAS_WEBHOOK_URL:}\nconsumoesperto.alertas.cooldown-minutes=${ALERTAS_COOLDOWN_MINUTES:15}\nconsumoesperto.alertas.timeout-ms=${ALERTAS_TIMEOUT_MS:5000}\n\n# Memória semântica J.A.R.V.I.S. — higiene de escrita, RAG híbrido e ciclo de vida\nconsumoesperto.memoria.dedupe-similaridade-minima=${MEMORIA_DEDUPE_SIMILARIDADE:0.90}\nconsumoesperto.memoria.superacao-similaridade-minima=${MEMORIA_SUPERACAO_SIMILARIDADE:0.78}\nconsumoesperto.memoria.superacao-similaridade-sem-categoria=${MEMORIA_SUPERACAO_SIMILARIDADE_SEM_CATEGORIA:0.88}\nconsumoesperto.memoria.rag-limite-contexto=${MEMORIA_RAG_LIMITE_CONTEXTO:5}\nconsumoesperto.memoria.rag-meia-vida-dias=${MEMORIA_RAG_MEIA_VIDA_DIAS:90}\nconsumoesperto.memoria.habito-min-ocorrencias=${MEMORIA_HABITO_MIN_OCORRENCIAS:5}\nconsumoesperto.memoria.habito-min-dias-distintos=${MEMORIA_HABITO_MIN_DIAS_DISTINTOS:3}\nconsumoesperto.memoria.habito-janela-minutos-duplicata=${MEMORIA_HABITO_JANELA_DUPLICATA_MIN:30}\nconsumoesperto.memoria.decaimento-confianca-mensal=${MEMORIA_DECAIMENTO_MENSAL:0.05}\nconsumoesperto.memoria.confianca-piso=${MEMORIA_CONFIANCA_PISO:0.25}\nconsumoesperto.memoria.consolidacao-min-cluster=${MEMORIA_CONSOLIDACAO_MIN_CLUSTER:6}\nconsumoesperto.memoria.consolidacao-similaridade=${MEMORIA_CONSOLIDACAO_SIMILARIDADE:0.85}\nconsumoesperto.memoria.captura-automatica-enabled=${MEMORIA_CAPTURA_AUTOMATICA_ENABLED:true}\n\n# Amortização sazonal — taxa de empréstimo padrão e desconto de antecipação para parcelas sem juros\nconsumoesperto.amortizacao.taxa-juros-mensal-padrao=0.0199\nconsumoesperto.amortizacao.taxa-desconto-sem-juros=0.005\n\n# Benchmark BCB por tipo de operação (séries SGS + fallback % a.a.)\nconsumoesperto.market.bcb.serie.cartao=25438\nconsumoesperto.market.bcb.serie.pessoal=25463\nconsumoesperto.market.bcb.serie.imobiliario=20743\nconsumoesperto.market.bcb.serie.veiculo=20746\nconsumoesperto.market.fallback.taxa-cartao-aa=130\nconsumoesperto.market.fallback.taxa-pessoal-aa=55\nconsumoesperto.market.fallback.taxa-imobiliario-aa=12\nconsumoesperto.market.fallback.taxa-veiculo-aa=22\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:false}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:false}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:false}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:false}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:600000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:90000}\n# Sessão persistente: mantém ligado até o utilizador desligar explicitamente na app\nconsumoesperto.evolution.session.sticky=${EVOLUTION_SESSION_STICKY:true}\n# Watchdog: deteta sessão fantasma (open na API, Baileys morto) e reinicia instância + webhook\nconsumoesperto.evolution.watchdog.enabled=${EVOLUTION_WATCHDOG_ENABLED:true}\nconsumoesperto.evolution.watchdog.interval-ms=${EVOLUTION_WATCHDOG_INTERVAL_MS:300000}\n# Keepalive (4h): webhook + reconnect leve + presença unavailable — sem restart destrutivo\nconsumoesperto.evolution.keepalive.interval-ms=${EVOLUTION_KEEPALIVE_INTERVAL_MS:14400000}\n# Admin ops (health Evolution): header X-Admin-Api-Key\nconsumoesperto.admin.api-key=${ADMIN_API_KEY:}\nconsumoesperto.evolution.metrics.instabilidade.scan-ms=${EVOLUTION_METRICS_SCAN_MS:300000}\nconsumoesperto.evolution.metrics.instabilidade.desconexoes-dia=${EVOLUTION_METRICS_DESCONEXOES_DIA:3}\nconsumoesperto.evolution.metrics.instabilidade.reconexoes-dia=${EVOLUTION_METRICS_RECONEXOES_DIA:5}\nconsumoesperto.evolution.metrics.instabilidade.inatividade-min=${EVOLUTION_METRICS_INATIVIDADE_MIN:45}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# Performance J.A.R.V.I.S. — cache, janela multi-turno, métricas, timeouts de parse\nconsumoesperto.jarvis.performance.contexto-cache-ttl-seconds=${JARVIS_CONTEXTO_CACHE_TTL_SECONDS:90}\nconsumoesperto.jarvis.performance.market-cache-ttl-seconds=${JARVIS_MARKET_CACHE_TTL_SECONDS:7200}\nconsumoesperto.jarvis.performance.conversa-janela-ttl-seconds=${JARVIS_CONVERSA_JANELA_TTL_SECONDS:3600}\nconsumoesperto.jarvis.performance.conversa-janela-max-trocas=${JARVIS_CONVERSA_JANELA_MAX_TROCAS:6}\nconsumoesperto.jarvis.performance.ack-long-operation-ms=${JARVIS_ACK_LONG_OPERATION_MS:3000}\nconsumoesperto.jarvis.performance.parse-read-timeout-ms=${JARVIS_PARSE_READ_TIMEOUT_MS:25000}\nconsumoesperto.jarvis.performance.metrics-sample-size=${JARVIS_METRICS_SAMPLE_SIZE:500}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "bbffbe827bb0575797200e3face3f9282e55af32": {"t": 1785752800.1080976, "n": 47665, "h": "17856b13b0fb30bf", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Projeção de fechamento do mês (forecast / J.A.R.V.I.S.)\nconsumoesperto.forecast.dia-liminar-anti-susto=15\nconsumoesperto.forecast.margem-variavel-pct=10\n# Tolerância de dedup de provisão: percentual (default 10%) + piso absoluto em R$\nconsumoesperto.provisao.tolerancia-dedup-pct=10\nconsumoesperto.provisao.tolerancia-dedup-piso=2.00\n\n# Simulação de compra — divisor = meses com despesa confirmada (mínimo configurável)\nconsumoesperto.simulacao.meses-janela=6\nconsumoesperto.simulacao.meses-minimo-divisor=1\n\n# Burn rate Sentinela — janela máxima em dias (divisor = dias reais na janela)\nconsumoesperto.projecao.burn-dias-amostra=31\n\n# Webhook Evolution — autenticação fail-closed (header customizado e/ou token na query)\n# Rollback emergencial: evolution.webhook.auth-required=false\nevolution.webhook.auth-required=${EVOLUTION_WEBHOOK_AUTH_REQUIRED:true}\nevolution.webhook.secret=${EVOLUTION_WEBHOOK_SECRET:${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}}\nevolution.webhook.header-name=${EVOLUTION_WEBHOOK_HEADER_NAME:X-ConsumoEsperto-Webhook-Secret}\nevolution.webhook.query-param=${EVOLUTION_WEBHOOK_QUERY_PARAM:token}\nevolution.webhook.accept-legacy-apikey-header=true\n# Alias legado (mantido para compatibilidade de docs antigas):\nevolution.webhook.apikey-required=${evolution.webhook.auth-required}\n\n# Rate limit IA por usuário e por número WhatsApp (Bucket4j in-memory — VPS única)\nconsumoesperto.ai.rate-limit.requests-per-minute=30\nconsumoesperto.ai.rate-limit.whatsapp-per-number-per-minute=20\nconsumoesperto.ai.media.max-pdf-bytes=15728640\nconsumoesperto.ai.media.max-audio-bytes=26214400\n\n# Dedup webhook — retenção em dias (job de expurgo diário)\nconsumoesperto.webhook.dedup.retention-days=30\n\n# Integridade de saldo — detecção diária; correção automática desligada por padrão\nconsumoesperto.saldo.integridade.auto-corrigir=false\nconsumoesperto.saldo.integridade.cron=0 30 3 * * *\n\n# Reparo de dados financeiros — fail-closed: false = só dry-run/relatório (nada é escrito)\nconsumoesperto.saldo.reparo.enabled=${SALDO_REPARO_ENABLED:false}\n\n# Audit trail de saldo (movimentacao_saldo_log) — retenção do expurgo diário\nconsumoesperto.saldo.audit.retention-days=${SALDO_AUDIT_RETENTION_DAYS:730}\n\n# Alertas operacionais: log ERROR sempre; webhook externo opcional (divergência de saldo, auth do webhook)\nconsumoesperto.alertas.webhook-enabled=${ALERTAS_WEBHOOK_ENABLED:false}\nconsumoesperto.alertas.webhook-url=${ALERTAS_WEBHOOK_URL:}\nconsumoesperto.alertas.cooldown-minutes=${ALERTAS_COOLDOWN_MINUTES:15}\nconsumoesperto.alertas.timeout-ms=${ALERTAS_TIMEOUT_MS:5000}\n\n# Memória semântica J.A.R.V.I.S. — higiene de escrita, RAG híbrido e ciclo de vida\nconsumoesperto.memoria.dedupe-similaridade-minima=${MEMORIA_DEDUPE_SIMILARIDADE:0.90}\nconsumoesperto.memoria.superacao-similaridade-minima=${MEMORIA_SUPERACAO_SIMILARIDADE:0.78}\nconsumoesperto.memoria.superacao-similaridade-sem-categoria=${MEMORIA_SUPERACAO_SIMILARIDADE_SEM_CATEGORIA:0.88}\nconsumoesperto.memoria.rag-limite-contexto=${MEMORIA_RAG_LIMITE_CONTEXTO:5}\nconsumoesperto.memoria.rag-meia-vida-dias=${MEMORIA_RAG_MEIA_VIDA_DIAS:90}\nconsumoesperto.memoria.habito-min-ocorrencias=${MEMORIA_HABITO_MIN_OCORRENCIAS:5}\nconsumoesperto.memoria.habito-min-dias-distintos=${MEMORIA_HABITO_MIN_DIAS_DISTINTOS:3}\nconsumoesperto.memoria.habito-janela-minutos-duplicata=${MEMORIA_HABITO_JANELA_DUPLICATA_MIN:30}\nconsumoesperto.memoria.decaimento-confianca-mensal=${MEMORIA_DECAIMENTO_MENSAL:0.05}\nconsumoesperto.memoria.confianca-piso=${MEMORIA_CONFIANCA_PISO:0.25}\nconsumoesperto.memoria.consolidacao-min-cluster=${MEMORIA_CONSOLIDACAO_MIN_CLUSTER:6}\nconsumoesperto.memoria.consolidacao-similaridade=${MEMORIA_CONSOLIDACAO_SIMILARIDADE:0.85}\nconsumoesperto.memoria.captura-automatica-enabled=${MEMORIA_CAPTURA_AUTOMATICA_ENABLED:true}\n\n# Amortização sazonal — taxa de empréstimo padrão e desconto de antecipação para parcelas sem juros\nconsumoesperto.amortizacao.taxa-juros-mensal-padrao=0.0199\nconsumoesperto.amortizacao.taxa-desconto-sem-juros=0.005\n\n# Benchmark BCB por tipo de operação (séries SGS + fallback % a.a.)\nconsumoesperto.market.bcb.serie.cartao=25438\nconsumoesperto.market.bcb.serie.pessoal=25463\nconsumoesperto.market.bcb.serie.imobiliario=20743\nconsumoesperto.market.bcb.serie.veiculo=20746\nconsumoesperto.market.fallback.taxa-cartao-aa=130\nconsumoesperto.market.fallback.taxa-pessoal-aa=55\nconsumoesperto.market.fallback.taxa-imobiliario-aa=12\nconsumoesperto.market.fallback.taxa-veiculo-aa=22\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:false}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:false}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:false}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:false}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:600000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:90000}\n# Sessão persistente: mantém ligado até o utilizador desligar explicitamente na app\nconsumoesperto.evolution.session.sticky=${EVOLUTION_SESSION_STICKY:true}\n# Watchdog: deteta sessão fantasma (open na API, Baileys morto) e reinicia instância + webhook\nconsumoesperto.evolution.watchdog.enabled=${EVOLUTION_WATCHDOG_ENABLED:true}\nconsumoesperto.evolution.watchdog.interval-ms=${EVOLUTION_WATCHDOG_INTERVAL_MS:300000}\n# Keepalive (4h): webhook + reconnect leve + presença unavailable — sem restart destrutivo\nconsumoesperto.evolution.keepalive.interval-ms=${EVOLUTION_KEEPALIVE_INTERVAL_MS:14400000}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# Performance J.A.R.V.I.S. — cache, janela multi-turno, métricas, timeouts de parse\nconsumoesperto.jarvis.performance.contexto-cache-ttl-seconds=${JARVIS_CONTEXTO_CACHE_TTL_SECONDS:90}\nconsumoesperto.jarvis.performance.market-cache-ttl-seconds=${JARVIS_MARKET_CACHE_TTL_SECONDS:7200}\nconsumoesperto.jarvis.performance.conversa-janela-ttl-seconds=${JARVIS_CONVERSA_JANELA_TTL_SECONDS:3600}\nconsumoesperto.jarvis.performance.conversa-janela-max-trocas=${JARVIS_CONVERSA_JANELA_MAX_TROCAS:6}\nconsumoesperto.jarvis.performance.ack-long-operation-ms=${JARVIS_ACK_LONG_OPERATION_MS:3000}\nconsumoesperto.jarvis.performance.parse-read-timeout-ms=${JARVIS_PARSE_READ_TIMEOUT_MS:25000}\nconsumoesperto.jarvis.performance.metrics-sample-size=${JARVIS_METRICS_SAMPLE_SIZE:500}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "e738d6400ecdabd133809b76481bc7093c902982": {"t": 1785752801.5787942, "n": 46900, "h": "4a5e8501686af4b8", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Projeção de fechamento do mês (forecast / J.A.R.V.I.S.)\nconsumoesperto.forecast.dia-liminar-anti-susto=15\nconsumoesperto.forecast.margem-variavel-pct=10\n# Tolerância de dedup de provisão: percentual (default 10%) + piso absoluto em R$\nconsumoesperto.provisao.tolerancia-dedup-pct=10\nconsumoesperto.provisao.tolerancia-dedup-piso=2.00\n\n# Simulação de compra — divisor = meses com despesa confirmada (mínimo configurável)\nconsumoesperto.simulacao.meses-janela=6\nconsumoesperto.simulacao.meses-minimo-divisor=1\n\n# Burn rate Sentinela — janela máxima em dias (divisor = dias reais na janela)\nconsumoesperto.projecao.burn-dias-amostra=31\n\n# Webhook Evolution — autenticação fail-closed (header customizado e/ou token na query)\n# Rollback emergencial: evolution.webhook.auth-required=false\nevolution.webhook.auth-required=${EVOLUTION_WEBHOOK_AUTH_REQUIRED:true}\nevolution.webhook.secret=${EVOLUTION_WEBHOOK_SECRET:${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}}\nevolution.webhook.header-name=${EVOLUTION_WEBHOOK_HEADER_NAME:X-ConsumoEsperto-Webhook-Secret}\nevolution.webhook.query-param=${EVOLUTION_WEBHOOK_QUERY_PARAM:token}\nevolution.webhook.accept-legacy-apikey-header=true\n# Alias legado (mantido para compatibilidade de docs antigas):\nevolution.webhook.apikey-required=${evolution.webhook.auth-required}\n\n# Rate limit IA por usuário e por número WhatsApp (Bucket4j in-memory — VPS única)\nconsumoesperto.ai.rate-limit.requests-per-minute=30\nconsumoesperto.ai.rate-limit.whatsapp-per-number-per-minute=20\nconsumoesperto.ai.media.max-pdf-bytes=15728640\nconsumoesperto.ai.media.max-audio-bytes=26214400\n\n# Dedup webhook — retenção em dias (job de expurgo diário)\nconsumoesperto.webhook.dedup.retention-days=30\n\n# Integridade de saldo — detecção diária; correção automática desligada por padrão\nconsumoesperto.saldo.integridade.auto-corrigir=false\nconsumoesperto.saldo.integridade.cron=0 30 3 * * *\n\n# Reparo de dados financeiros — fail-closed: false = só dry-run/relatório (nada é escrito)\nconsumoesperto.saldo.reparo.enabled=${SALDO_REPARO_ENABLED:false}\n\n# Audit trail de saldo (movimentacao_saldo_log) — retenção do expurgo diário\nconsumoesperto.saldo.audit.retention-days=${SALDO_AUDIT_RETENTION_DAYS:730}\n\n# Alertas operacionais: log ERROR sempre; webhook externo opcional (divergência de saldo, auth do webhook)\nconsumoesperto.alertas.webhook-enabled=${ALERTAS_WEBHOOK_ENABLED:false}\nconsumoesperto.alertas.webhook-url=${ALERTAS_WEBHOOK_URL:}\nconsumoesperto.alertas.cooldown-minutes=${ALERTAS_COOLDOWN_MINUTES:15}\nconsumoesperto.alertas.timeout-ms=${ALERTAS_TIMEOUT_MS:5000}\n\n# Memória semântica J.A.R.V.I.S. — higiene de escrita, RAG híbrido e ciclo de vida\nconsumoesperto.memoria.dedupe-similaridade-minima=${MEMORIA_DEDUPE_SIMILARIDADE:0.90}\nconsumoesperto.memoria.superacao-similaridade-minima=${MEMORIA_SUPERACAO_SIMILARIDADE:0.78}\nconsumoesperto.memoria.superacao-similaridade-sem-categoria=${MEMORIA_SUPERACAO_SIMILARIDADE_SEM_CATEGORIA:0.88}\nconsumoesperto.memoria.rag-limite-contexto=${MEMORIA_RAG_LIMITE_CONTEXTO:5}\nconsumoesperto.memoria.rag-meia-vida-dias=${MEMORIA_RAG_MEIA_VIDA_DIAS:90}\nconsumoesperto.memoria.habito-min-ocorrencias=${MEMORIA_HABITO_MIN_OCORRENCIAS:5}\nconsumoesperto.memoria.habito-min-dias-distintos=${MEMORIA_HABITO_MIN_DIAS_DISTINTOS:3}\nconsumoesperto.memoria.habito-janela-minutos-duplicata=${MEMORIA_HABITO_JANELA_DUPLICATA_MIN:30}\nconsumoesperto.memoria.decaimento-confianca-mensal=${MEMORIA_DECAIMENTO_MENSAL:0.05}\nconsumoesperto.memoria.confianca-piso=${MEMORIA_CONFIANCA_PISO:0.25}\nconsumoesperto.memoria.consolidacao-min-cluster=${MEMORIA_CONSOLIDACAO_MIN_CLUSTER:6}\nconsumoesperto.memoria.consolidacao-similaridade=${MEMORIA_CONSOLIDACAO_SIMILARIDADE:0.85}\nconsumoesperto.memoria.captura-automatica-enabled=${MEMORIA_CAPTURA_AUTOMATICA_ENABLED:true}\n\n# Amortização sazonal — taxa de empréstimo padrão e desconto de antecipação para parcelas sem juros\nconsumoesperto.amortizacao.taxa-juros-mensal-padrao=0.0199\nconsumoesperto.amortizacao.taxa-desconto-sem-juros=0.005\n\n# Benchmark BCB por tipo de operação (séries SGS + fallback % a.a.)\nconsumoesperto.market.bcb.serie.cartao=25438\nconsumoesperto.market.bcb.serie.pessoal=25463\nconsumoesperto.market.bcb.serie.imobiliario=20743\nconsumoesperto.market.bcb.serie.veiculo=20746\nconsumoesperto.market.fallback.taxa-cartao-aa=130\nconsumoesperto.market.fallback.taxa-pessoal-aa=55\nconsumoesperto.market.fallback.taxa-imobiliario-aa=12\nconsumoesperto.market.fallback.taxa-veiculo-aa=22\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:false}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:false}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:false}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:false}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:600000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:90000}\n# Sessão persistente: mantém ligado até o utilizador desligar explicitamente na app\nconsumoesperto.evolution.session.sticky=${EVOLUTION_SESSION_STICKY:true}\n# Watchdog: deteta sessão fantasma (open na API, Baileys morto) e reinicia instância + webhook\nconsumoesperto.evolution.watchdog.enabled=${EVOLUTION_WATCHDOG_ENABLED:true}\nconsumoesperto.evolution.watchdog.interval-ms=${EVOLUTION_WATCHDOG_INTERVAL_MS:300000}\n# Keepalive (4h): webhook + reconnect leve + presença unavailable — sem restart destrutivo\nconsumoesperto.evolution.keepalive.interval-ms=${EVOLUTION_KEEPALIVE_INTERVAL_MS:14400000}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "91fc78edbc00ea75d8e2f45964d9b14dd7ad13a6": {"t": 1785752803.8782692, "n": 46542, "h": "25d47637c569a32a", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Projeção de fechamento do mês (forecast / J.A.R.V.I.S.)\nconsumoesperto.forecast.dia-liminar-anti-susto=15\nconsumoesperto.forecast.margem-variavel-pct=10\n# Tolerância (R$) para deduplicar provisões (fixa × sazonal × PLANO_FUTURO)\nconsumoesperto.provisao.tolerancia-dedup=2.00\n\n# Webhook Evolution — autenticação fail-closed (header customizado e/ou token na query)\n# Rollback emergencial: evolution.webhook.auth-required=false\nevolution.webhook.auth-required=${EVOLUTION_WEBHOOK_AUTH_REQUIRED:true}\nevolution.webhook.secret=${EVOLUTION_WEBHOOK_SECRET:${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}}\nevolution.webhook.header-name=${EVOLUTION_WEBHOOK_HEADER_NAME:X-ConsumoEsperto-Webhook-Secret}\nevolution.webhook.query-param=${EVOLUTION_WEBHOOK_QUERY_PARAM:token}\nevolution.webhook.accept-legacy-apikey-header=true\n# Alias legado (mantido para compatibilidade de docs antigas):\nevolution.webhook.apikey-required=${evolution.webhook.auth-required}\n\n# Rate limit IA por usuário e por número WhatsApp (Bucket4j in-memory — VPS única)\nconsumoesperto.ai.rate-limit.requests-per-minute=30\nconsumoesperto.ai.rate-limit.whatsapp-per-number-per-minute=20\nconsumoesperto.ai.media.max-pdf-bytes=15728640\nconsumoesperto.ai.media.max-audio-bytes=26214400\n\n# Dedup webhook — retenção em dias (job de expurgo diário)\nconsumoesperto.webhook.dedup.retention-days=30\n\n# Integridade de saldo — detecção diária; correção automática desligada por padrão\nconsumoesperto.saldo.integridade.auto-corrigir=false\nconsumoesperto.saldo.integridade.cron=0 30 3 * * *\n\n# Reparo de dados financeiros — fail-closed: false = só dry-run/relatório (nada é escrito)\nconsumoesperto.saldo.reparo.enabled=${SALDO_REPARO_ENABLED:false}\n\n# Audit trail de saldo (movimentacao_saldo_log) — retenção do expurgo diário\nconsumoesperto.saldo.audit.retention-days=${SALDO_AUDIT_RETENTION_DAYS:730}\n\n# Alertas operacionais: log ERROR sempre; webhook externo opcional (divergência de saldo, auth do webhook)\nconsumoesperto.alertas.webhook-enabled=${ALERTAS_WEBHOOK_ENABLED:false}\nconsumoesperto.alertas.webhook-url=${ALERTAS_WEBHOOK_URL:}\nconsumoesperto.alertas.cooldown-minutes=${ALERTAS_COOLDOWN_MINUTES:15}\nconsumoesperto.alertas.timeout-ms=${ALERTAS_TIMEOUT_MS:5000}\n\n# Memória semântica J.A.R.V.I.S. — higiene de escrita, RAG híbrido e ciclo de vida\nconsumoesperto.memoria.dedupe-similaridade-minima=${MEMORIA_DEDUPE_SIMILARIDADE:0.90}\nconsumoesperto.memoria.superacao-similaridade-minima=${MEMORIA_SUPERACAO_SIMILARIDADE:0.78}\nconsumoesperto.memoria.superacao-similaridade-sem-categoria=${MEMORIA_SUPERACAO_SIMILARIDADE_SEM_CATEGORIA:0.88}\nconsumoesperto.memoria.rag-limite-contexto=${MEMORIA_RAG_LIMITE_CONTEXTO:5}\nconsumoesperto.memoria.rag-meia-vida-dias=${MEMORIA_RAG_MEIA_VIDA_DIAS:90}\nconsumoesperto.memoria.habito-min-ocorrencias=${MEMORIA_HABITO_MIN_OCORRENCIAS:5}\nconsumoesperto.memoria.habito-min-dias-distintos=${MEMORIA_HABITO_MIN_DIAS_DISTINTOS:3}\nconsumoesperto.memoria.habito-janela-minutos-duplicata=${MEMORIA_HABITO_JANELA_DUPLICATA_MIN:30}\nconsumoesperto.memoria.decaimento-confianca-mensal=${MEMORIA_DECAIMENTO_MENSAL:0.05}\nconsumoesperto.memoria.confianca-piso=${MEMORIA_CONFIANCA_PISO:0.25}\nconsumoesperto.memoria.consolidacao-min-cluster=${MEMORIA_CONSOLIDACAO_MIN_CLUSTER:6}\nconsumoesperto.memoria.consolidacao-similaridade=${MEMORIA_CONSOLIDACAO_SIMILARIDADE:0.85}\nconsumoesperto.memoria.captura-automatica-enabled=${MEMORIA_CAPTURA_AUTOMATICA_ENABLED:true}\n\n# Amortização sazonal — taxa de empréstimo padrão e desconto de antecipação para parcelas sem juros\nconsumoesperto.amortizacao.taxa-juros-mensal-padrao=0.0199\nconsumoesperto.amortizacao.taxa-desconto-sem-juros=0.005\n\n# Benchmark BCB por tipo de operação (séries SGS + fallback % a.a.)\nconsumoesperto.market.bcb.serie.cartao=25438\nconsumoesperto.market.bcb.serie.pessoal=25463\nconsumoesperto.market.bcb.serie.imobiliario=20743\nconsumoesperto.market.bcb.serie.veiculo=20746\nconsumoesperto.market.fallback.taxa-cartao-aa=130\nconsumoesperto.market.fallback.taxa-pessoal-aa=55\nconsumoesperto.market.fallback.taxa-imobiliario-aa=12\nconsumoesperto.market.fallback.taxa-veiculo-aa=22\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:false}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:false}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:false}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:false}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:600000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:90000}\n# Sessão persistente: mantém ligado até o utilizador desligar explicitamente na app\nconsumoesperto.evolution.session.sticky=${EVOLUTION_SESSION_STICKY:true}\n# Watchdog: deteta sessão fantasma (open na API, Baileys morto) e reinicia instância + webhook\nconsumoesperto.evolution.watchdog.enabled=${EVOLUTION_WATCHDOG_ENABLED:true}\nconsumoesperto.evolution.watchdog.interval-ms=${EVOLUTION_WATCHDOG_INTERVAL_MS:300000}\n# Keepalive (4h): webhook + reconnect leve + presença unavailable — sem restart destrutivo\nconsumoesperto.evolution.keepalive.interval-ms=${EVOLUTION_KEEPALIVE_INTERVAL_MS:14400000}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "4c83d979fe506c62203f4ba7c056513bd532de3d": {"t": 1785752805.7995293, "n": 46420, "h": "4c87450bd92a80b4", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Projeção de fechamento do mês (forecast / J.A.R.V.I.S.)\nconsumoesperto.forecast.dia-liminar-anti-susto=15\nconsumoesperto.forecast.margem-variavel-pct=10\n\n# Webhook Evolution — autenticação fail-closed (header customizado e/ou token na query)\n# Rollback emergencial: evolution.webhook.auth-required=false\nevolution.webhook.auth-required=${EVOLUTION_WEBHOOK_AUTH_REQUIRED:true}\nevolution.webhook.secret=${EVOLUTION_WEBHOOK_SECRET:${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}}\nevolution.webhook.header-name=${EVOLUTION_WEBHOOK_HEADER_NAME:X-ConsumoEsperto-Webhook-Secret}\nevolution.webhook.query-param=${EVOLUTION_WEBHOOK_QUERY_PARAM:token}\nevolution.webhook.accept-legacy-apikey-header=true\n# Alias legado (mantido para compatibilidade de docs antigas):\nevolution.webhook.apikey-required=${evolution.webhook.auth-required}\n\n# Rate limit IA por usuário e por número WhatsApp (Bucket4j in-memory — VPS única)\nconsumoesperto.ai.rate-limit.requests-per-minute=30\nconsumoesperto.ai.rate-limit.whatsapp-per-number-per-minute=20\nconsumoesperto.ai.media.max-pdf-bytes=15728640\nconsumoesperto.ai.media.max-audio-bytes=26214400\n\n# Dedup webhook — retenção em dias (job de expurgo diário)\nconsumoesperto.webhook.dedup.retention-days=30\n\n# Integridade de saldo — detecção diária; correção automática desligada por padrão\nconsumoesperto.saldo.integridade.auto-corrigir=false\nconsumoesperto.saldo.integridade.cron=0 30 3 * * *\n\n# Reparo de dados financeiros — fail-closed: false = só dry-run/relatório (nada é escrito)\nconsumoesperto.saldo.reparo.enabled=${SALDO_REPARO_ENABLED:false}\n\n# Audit trail de saldo (movimentacao_saldo_log) — retenção do expurgo diário\nconsumoesperto.saldo.audit.retention-days=${SALDO_AUDIT_RETENTION_DAYS:730}\n\n# Alertas operacionais: log ERROR sempre; webhook externo opcional (divergência de saldo, auth do webhook)\nconsumoesperto.alertas.webhook-enabled=${ALERTAS_WEBHOOK_ENABLED:false}\nconsumoesperto.alertas.webhook-url=${ALERTAS_WEBHOOK_URL:}\nconsumoesperto.alertas.cooldown-minutes=${ALERTAS_COOLDOWN_MINUTES:15}\nconsumoesperto.alertas.timeout-ms=${ALERTAS_TIMEOUT_MS:5000}\n\n# Memória semântica J.A.R.V.I.S. — higiene de escrita, RAG híbrido e ciclo de vida\nconsumoesperto.memoria.dedupe-similaridade-minima=${MEMORIA_DEDUPE_SIMILARIDADE:0.90}\nconsumoesperto.memoria.superacao-similaridade-minima=${MEMORIA_SUPERACAO_SIMILARIDADE:0.78}\nconsumoesperto.memoria.superacao-similaridade-sem-categoria=${MEMORIA_SUPERACAO_SIMILARIDADE_SEM_CATEGORIA:0.88}\nconsumoesperto.memoria.rag-limite-contexto=${MEMORIA_RAG_LIMITE_CONTEXTO:5}\nconsumoesperto.memoria.rag-meia-vida-dias=${MEMORIA_RAG_MEIA_VIDA_DIAS:90}\nconsumoesperto.memoria.habito-min-ocorrencias=${MEMORIA_HABITO_MIN_OCORRENCIAS:5}\nconsumoesperto.memoria.habito-min-dias-distintos=${MEMORIA_HABITO_MIN_DIAS_DISTINTOS:3}\nconsumoesperto.memoria.habito-janela-minutos-duplicata=${MEMORIA_HABITO_JANELA_DUPLICATA_MIN:30}\nconsumoesperto.memoria.decaimento-confianca-mensal=${MEMORIA_DECAIMENTO_MENSAL:0.05}\nconsumoesperto.memoria.confianca-piso=${MEMORIA_CONFIANCA_PISO:0.25}\nconsumoesperto.memoria.consolidacao-min-cluster=${MEMORIA_CONSOLIDACAO_MIN_CLUSTER:6}\nconsumoesperto.memoria.consolidacao-similaridade=${MEMORIA_CONSOLIDACAO_SIMILARIDADE:0.85}\nconsumoesperto.memoria.captura-automatica-enabled=${MEMORIA_CAPTURA_AUTOMATICA_ENABLED:true}\n\n# Amortização sazonal — taxa de empréstimo padrão e desconto de antecipação para parcelas sem juros\nconsumoesperto.amortizacao.taxa-juros-mensal-padrao=0.0199\nconsumoesperto.amortizacao.taxa-desconto-sem-juros=0.005\n\n# Benchmark BCB por tipo de operação (séries SGS + fallback % a.a.)\nconsumoesperto.market.bcb.serie.cartao=25438\nconsumoesperto.market.bcb.serie.pessoal=25463\nconsumoesperto.market.bcb.serie.imobiliario=20743\nconsumoesperto.market.bcb.serie.veiculo=20746\nconsumoesperto.market.fallback.taxa-cartao-aa=130\nconsumoesperto.market.fallback.taxa-pessoal-aa=55\nconsumoesperto.market.fallback.taxa-imobiliario-aa=12\nconsumoesperto.market.fallback.taxa-veiculo-aa=22\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:false}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:false}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:false}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:false}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:600000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:90000}\n# Sessão persistente: mantém ligado até o utilizador desligar explicitamente na app\nconsumoesperto.evolution.session.sticky=${EVOLUTION_SESSION_STICKY:true}\n# Watchdog: deteta sessão fantasma (open na API, Baileys morto) e reinicia instância + webhook\nconsumoesperto.evolution.watchdog.enabled=${EVOLUTION_WATCHDOG_ENABLED:true}\nconsumoesperto.evolution.watchdog.interval-ms=${EVOLUTION_WATCHDOG_INTERVAL_MS:300000}\n# Keepalive (4h): webhook + reconnect leve + presença unavailable — sem restart destrutivo\nconsumoesperto.evolution.keepalive.interval-ms=${EVOLUTION_KEEPALIVE_INTERVAL_MS:14400000}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "5aa69785e82716ad7404eefeb9e0d7d6df5ffeff": {"t": 1785752807.51782, "n": 46307, "h": "0b2b953a975466b1", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Projeção de fechamento do mês (forecast / J.A.R.V.I.S.)\nconsumoesperto.forecast.dia-liminar-anti-susto=15\nconsumoesperto.forecast.margem-variavel-pct=10\n\n# Webhook Evolution — autenticação fail-closed (header customizado e/ou token na query)\n# Rollback emergencial: evolution.webhook.auth-required=false\nevolution.webhook.auth-required=${EVOLUTION_WEBHOOK_AUTH_REQUIRED:true}\nevolution.webhook.secret=${EVOLUTION_WEBHOOK_SECRET:${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}}\nevolution.webhook.header-name=${EVOLUTION_WEBHOOK_HEADER_NAME:X-ConsumoEsperto-Webhook-Secret}\nevolution.webhook.query-param=${EVOLUTION_WEBHOOK_QUERY_PARAM:token}\nevolution.webhook.accept-legacy-apikey-header=true\n# Alias legado (mantido para compatibilidade de docs antigas):\nevolution.webhook.apikey-required=${evolution.webhook.auth-required}\n\n# Rate limit IA por usuário e por número WhatsApp (Bucket4j in-memory — VPS única)\nconsumoesperto.ai.rate-limit.requests-per-minute=30\nconsumoesperto.ai.rate-limit.whatsapp-per-number-per-minute=20\nconsumoesperto.ai.media.max-pdf-bytes=15728640\nconsumoesperto.ai.media.max-audio-bytes=26214400\n\n# Dedup webhook — retenção em dias (job de expurgo diário)\nconsumoesperto.webhook.dedup.retention-days=30\n\n# Integridade de saldo — detecção diária; correção automática desligada por padrão\nconsumoesperto.saldo.integridade.auto-corrigir=false\nconsumoesperto.saldo.integridade.cron=0 30 3 * * *\n\n# Reparo de dados financeiros — fail-closed: false = só dry-run/relatório (nada é escrito)\nconsumoesperto.saldo.reparo.enabled=${SALDO_REPARO_ENABLED:false}\n\n# Audit trail de saldo (movimentacao_saldo_log) — retenção do expurgo diário\nconsumoesperto.saldo.audit.retention-days=${SALDO_AUDIT_RETENTION_DAYS:730}\n\n# Alertas operacionais: log ERROR sempre; webhook externo opcional (divergência de saldo, auth do webhook)\nconsumoesperto.alertas.webhook-enabled=${ALERTAS_WEBHOOK_ENABLED:false}\nconsumoesperto.alertas.webhook-url=${ALERTAS_WEBHOOK_URL:}\nconsumoesperto.alertas.cooldown-minutes=${ALERTAS_COOLDOWN_MINUTES:15}\nconsumoesperto.alertas.timeout-ms=${ALERTAS_TIMEOUT_MS:5000}\n\n# Memória semântica J.A.R.V.I.S. — higiene de escrita, RAG híbrido e ciclo de vida\nconsumoesperto.memoria.dedupe-similaridade-minima=${MEMORIA_DEDUPE_SIMILARIDADE:0.90}\nconsumoesperto.memoria.superacao-similaridade-minima=${MEMORIA_SUPERACAO_SIMILARIDADE:0.78}\nconsumoesperto.memoria.rag-limite-contexto=${MEMORIA_RAG_LIMITE_CONTEXTO:5}\nconsumoesperto.memoria.rag-meia-vida-dias=${MEMORIA_RAG_MEIA_VIDA_DIAS:90}\nconsumoesperto.memoria.habito-min-ocorrencias=${MEMORIA_HABITO_MIN_OCORRENCIAS:5}\nconsumoesperto.memoria.habito-min-dias-distintos=${MEMORIA_HABITO_MIN_DIAS_DISTINTOS:3}\nconsumoesperto.memoria.habito-janela-minutos-duplicata=${MEMORIA_HABITO_JANELA_DUPLICATA_MIN:30}\nconsumoesperto.memoria.decaimento-confianca-mensal=${MEMORIA_DECAIMENTO_MENSAL:0.05}\nconsumoesperto.memoria.confianca-piso=${MEMORIA_CONFIANCA_PISO:0.25}\nconsumoesperto.memoria.consolidacao-min-cluster=${MEMORIA_CONSOLIDACAO_MIN_CLUSTER:6}\nconsumoesperto.memoria.consolidacao-similaridade=${MEMORIA_CONSOLIDACAO_SIMILARIDADE:0.85}\nconsumoesperto.memoria.captura-automatica-enabled=${MEMORIA_CAPTURA_AUTOMATICA_ENABLED:true}\n\n# Amortização sazonal — taxa de empréstimo padrão e desconto de antecipação para parcelas sem juros\nconsumoesperto.amortizacao.taxa-juros-mensal-padrao=0.0199\nconsumoesperto.amortizacao.taxa-desconto-sem-juros=0.005\n\n# Benchmark BCB por tipo de operação (séries SGS + fallback % a.a.)\nconsumoesperto.market.bcb.serie.cartao=25438\nconsumoesperto.market.bcb.serie.pessoal=25463\nconsumoesperto.market.bcb.serie.imobiliario=20743\nconsumoesperto.market.bcb.serie.veiculo=20746\nconsumoesperto.market.fallback.taxa-cartao-aa=130\nconsumoesperto.market.fallback.taxa-pessoal-aa=55\nconsumoesperto.market.fallback.taxa-imobiliario-aa=12\nconsumoesperto.market.fallback.taxa-veiculo-aa=22\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:false}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:false}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:false}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:false}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:600000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:90000}\n# Sessão persistente: mantém ligado até o utilizador desligar explicitamente na app\nconsumoesperto.evolution.session.sticky=${EVOLUTION_SESSION_STICKY:true}\n# Watchdog: deteta sessão fantasma (open na API, Baileys morto) e reinicia instância + webhook\nconsumoesperto.evolution.watchdog.enabled=${EVOLUTION_WATCHDOG_ENABLED:true}\nconsumoesperto.evolution.watchdog.interval-ms=${EVOLUTION_WATCHDOG_INTERVAL_MS:300000}\n# Keepalive (4h): webhook + reconnect leve + presença unavailable — sem restart destrutivo\nconsumoesperto.evolution.keepalive.interval-ms=${EVOLUTION_KEEPALIVE_INTERVAL_MS:14400000}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "a13e3ba6c4dfb32caf4c2884107afa096a12a8ff": {"t": 1785752809.2519479, "n": 45203, "h": "60c6bf9870bebd8b", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Projeção de fechamento do mês (forecast / J.A.R.V.I.S.)\nconsumoesperto.forecast.dia-liminar-anti-susto=15\nconsumoesperto.forecast.margem-variavel-pct=10\n\n# Webhook Evolution — autenticação fail-closed (header customizado e/ou token na query)\n# Rollback emergencial: evolution.webhook.auth-required=false\nevolution.webhook.auth-required=${EVOLUTION_WEBHOOK_AUTH_REQUIRED:true}\nevolution.webhook.secret=${EVOLUTION_WEBHOOK_SECRET:${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}}\nevolution.webhook.header-name=${EVOLUTION_WEBHOOK_HEADER_NAME:X-ConsumoEsperto-Webhook-Secret}\nevolution.webhook.query-param=${EVOLUTION_WEBHOOK_QUERY_PARAM:token}\nevolution.webhook.accept-legacy-apikey-header=true\n# Alias legado (mantido para compatibilidade de docs antigas):\nevolution.webhook.apikey-required=${evolution.webhook.auth-required}\n\n# Rate limit IA por usuário e por número WhatsApp (Bucket4j in-memory — VPS única)\nconsumoesperto.ai.rate-limit.requests-per-minute=30\nconsumoesperto.ai.rate-limit.whatsapp-per-number-per-minute=20\nconsumoesperto.ai.media.max-pdf-bytes=15728640\nconsumoesperto.ai.media.max-audio-bytes=26214400\n\n# Dedup webhook — retenção em dias (job de expurgo diário)\nconsumoesperto.webhook.dedup.retention-days=30\n\n# Integridade de saldo — detecção diária; correção automática desligada por padrão\nconsumoesperto.saldo.integridade.auto-corrigir=false\nconsumoesperto.saldo.integridade.cron=0 30 3 * * *\n\n# Reparo de dados financeiros — fail-closed: false = só dry-run/relatório (nada é escrito)\nconsumoesperto.saldo.reparo.enabled=${SALDO_REPARO_ENABLED:false}\n\n# Audit trail de saldo (movimentacao_saldo_log) — retenção do expurgo diário\nconsumoesperto.saldo.audit.retention-days=${SALDO_AUDIT_RETENTION_DAYS:730}\n\n# Alertas operacionais: log ERROR sempre; webhook externo opcional (divergência de saldo, auth do webhook)\nconsumoesperto.alertas.webhook-enabled=${ALERTAS_WEBHOOK_ENABLED:false}\nconsumoesperto.alertas.webhook-url=${ALERTAS_WEBHOOK_URL:}\nconsumoesperto.alertas.cooldown-minutes=${ALERTAS_COOLDOWN_MINUTES:15}\nconsumoesperto.alertas.timeout-ms=${ALERTAS_TIMEOUT_MS:5000}\n\n# Amortização sazonal — taxa de empréstimo padrão e desconto de antecipação para parcelas sem juros\nconsumoesperto.amortizacao.taxa-juros-mensal-padrao=0.0199\nconsumoesperto.amortizacao.taxa-desconto-sem-juros=0.005\n\n# Benchmark BCB por tipo de operação (séries SGS + fallback % a.a.)\nconsumoesperto.market.bcb.serie.cartao=25438\nconsumoesperto.market.bcb.serie.pessoal=25463\nconsumoesperto.market.bcb.serie.imobiliario=20743\nconsumoesperto.market.bcb.serie.veiculo=20746\nconsumoesperto.market.fallback.taxa-cartao-aa=130\nconsumoesperto.market.fallback.taxa-pessoal-aa=55\nconsumoesperto.market.fallback.taxa-imobiliario-aa=12\nconsumoesperto.market.fallback.taxa-veiculo-aa=22\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:false}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:false}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:false}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:false}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:600000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:90000}\n# Sessão persistente: mantém ligado até o utilizador desligar explicitamente na app\nconsumoesperto.evolution.session.sticky=${EVOLUTION_SESSION_STICKY:true}\n# Watchdog: deteta sessão fantasma (open na API, Baileys morto) e reinicia instância + webhook\nconsumoesperto.evolution.watchdog.enabled=${EVOLUTION_WATCHDOG_ENABLED:true}\nconsumoesperto.evolution.watchdog.interval-ms=${EVOLUTION_WATCHDOG_INTERVAL_MS:300000}\n# Keepalive (4h): webhook + reconnect leve + presença unavailable — sem restart destrutivo\nconsumoesperto.evolution.keepalive.interval-ms=${EVOLUTION_KEEPALIVE_INTERVAL_MS:14400000}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "4f6f41a3bd6f88d2a66837e9350d78c1d061192a": {"t": 1785752810.7027435, "n": 44520, "h": "67878e9fd272ff90", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Projeção de fechamento do mês (forecast / J.A.R.V.I.S.)\nconsumoesperto.forecast.dia-liminar-anti-susto=15\nconsumoesperto.forecast.margem-variavel-pct=10\n\n# Webhook Evolution — autenticação fail-closed (header customizado e/ou token na query)\n# Rollback emergencial: evolution.webhook.auth-required=false\nevolution.webhook.auth-required=${EVOLUTION_WEBHOOK_AUTH_REQUIRED:true}\nevolution.webhook.secret=${EVOLUTION_WEBHOOK_SECRET:${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}}\nevolution.webhook.header-name=${EVOLUTION_WEBHOOK_HEADER_NAME:X-ConsumoEsperto-Webhook-Secret}\nevolution.webhook.query-param=${EVOLUTION_WEBHOOK_QUERY_PARAM:token}\nevolution.webhook.accept-legacy-apikey-header=true\n# Alias legado (mantido para compatibilidade de docs antigas):\nevolution.webhook.apikey-required=${evolution.webhook.auth-required}\n\n# Rate limit IA por usuário e por número WhatsApp (Bucket4j in-memory — VPS única)\nconsumoesperto.ai.rate-limit.requests-per-minute=30\nconsumoesperto.ai.rate-limit.whatsapp-per-number-per-minute=20\nconsumoesperto.ai.media.max-pdf-bytes=15728640\nconsumoesperto.ai.media.max-audio-bytes=26214400\n\n# Dedup webhook — retenção em dias (job de expurgo diário)\nconsumoesperto.webhook.dedup.retention-days=30\n\n# Integridade de saldo — detecção diária; correção automática desligada por padrão\nconsumoesperto.saldo.integridade.auto-corrigir=false\nconsumoesperto.saldo.integridade.cron=0 30 3 * * *\n\n# Amortização sazonal — taxa de empréstimo padrão e desconto de antecipação para parcelas sem juros\nconsumoesperto.amortizacao.taxa-juros-mensal-padrao=0.0199\nconsumoesperto.amortizacao.taxa-desconto-sem-juros=0.005\n\n# Benchmark BCB por tipo de operação (séries SGS + fallback % a.a.)\nconsumoesperto.market.bcb.serie.cartao=25438\nconsumoesperto.market.bcb.serie.pessoal=25463\nconsumoesperto.market.bcb.serie.imobiliario=20743\nconsumoesperto.market.bcb.serie.veiculo=20746\nconsumoesperto.market.fallback.taxa-cartao-aa=130\nconsumoesperto.market.fallback.taxa-pessoal-aa=55\nconsumoesperto.market.fallback.taxa-imobiliario-aa=12\nconsumoesperto.market.fallback.taxa-veiculo-aa=22\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:false}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:false}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:false}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:false}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:600000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:90000}\n# Sessão persistente: mantém ligado até o utilizador desligar explicitamente na app\nconsumoesperto.evolution.session.sticky=${EVOLUTION_SESSION_STICKY:true}\n# Watchdog: deteta sessão fantasma (open na API, Baileys morto) e reinicia instância + webhook\nconsumoesperto.evolution.watchdog.enabled=${EVOLUTION_WATCHDOG_ENABLED:true}\nconsumoesperto.evolution.watchdog.interval-ms=${EVOLUTION_WATCHDOG_INTERVAL_MS:300000}\n# Keepalive (4h): webhook + reconnect leve + presença unavailable — sem restart destrutivo\nconsumoesperto.evolution.keepalive.interval-ms=${EVOLUTION_KEEPALIVE_INTERVAL_MS:14400000}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "74d613046784eb4ebe0b3e8a5aaa3630ca2ba4c7": {"t": 1785752815.6503835, "n": 43265, "h": "98cebb66d95ddeff", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Projeção de fechamento do mês (forecast / J.A.R.V.I.S.)\nconsumoesperto.forecast.dia-liminar-anti-susto=15\nconsumoesperto.forecast.margem-variavel-pct=10\n\n# Amortização sazonal — taxa de empréstimo padrão e desconto de antecipação para parcelas sem juros\nconsumoesperto.amortizacao.taxa-juros-mensal-padrao=0.0199\nconsumoesperto.amortizacao.taxa-desconto-sem-juros=0.005\n\n# Benchmark BCB por tipo de operação (séries SGS + fallback % a.a.)\nconsumoesperto.market.bcb.serie.cartao=25438\nconsumoesperto.market.bcb.serie.pessoal=25463\nconsumoesperto.market.bcb.serie.imobiliario=20743\nconsumoesperto.market.bcb.serie.veiculo=20746\nconsumoesperto.market.fallback.taxa-cartao-aa=130\nconsumoesperto.market.fallback.taxa-pessoal-aa=55\nconsumoesperto.market.fallback.taxa-imobiliario-aa=12\nconsumoesperto.market.fallback.taxa-veiculo-aa=22\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:false}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:false}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:false}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:false}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:600000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:90000}\n# Sessão persistente: mantém ligado até o utilizador desligar explicitamente na app\nconsumoesperto.evolution.session.sticky=${EVOLUTION_SESSION_STICKY:true}\n# Watchdog: deteta sessão fantasma (open na API, Baileys morto) e reinicia instância + webhook\nconsumoesperto.evolution.watchdog.enabled=${EVOLUTION_WATCHDOG_ENABLED:true}\nconsumoesperto.evolution.watchdog.interval-ms=${EVOLUTION_WATCHDOG_INTERVAL_MS:300000}\n# Keepalive (4h): webhook + reconnect leve + presença unavailable — sem restart destrutivo\nconsumoesperto.evolution.keepalive.interval-ms=${EVOLUTION_KEEPALIVE_INTERVAL_MS:14400000}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "bd81beadbf24691c456a6d26c1065e54ffcd76a3": {"t": 1785752817.7512572, "n": 42588, "h": "436191b693397c7e", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Projeção de fechamento do mês (forecast / J.A.R.V.I.S.)\nconsumoesperto.forecast.dia-liminar-anti-susto=25\nconsumoesperto.forecast.margem-variavel-pct=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:false}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:false}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:false}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:false}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:600000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:90000}\n# Sessão persistente: mantém ligado até o utilizador desligar explicitamente na app\nconsumoesperto.evolution.session.sticky=${EVOLUTION_SESSION_STICKY:true}\n# Watchdog: deteta sessão fantasma (open na API, Baileys morto) e reinicia instância + webhook\nconsumoesperto.evolution.watchdog.enabled=${EVOLUTION_WATCHDOG_ENABLED:true}\nconsumoesperto.evolution.watchdog.interval-ms=${EVOLUTION_WATCHDOG_INTERVAL_MS:300000}\n# Keepalive (4h): webhook + reconnect leve + presença unavailable — sem restart destrutivo\nconsumoesperto.evolution.keepalive.interval-ms=${EVOLUTION_KEEPALIVE_INTERVAL_MS:14400000}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "973ad5d07edb755c5460e759ea74cf3baa09a785": {"t": 1785752819.453265, "n": 42432, "h": "9e107839d334b549", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:false}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:false}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:false}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:false}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:600000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:90000}\n# Sessão persistente: mantém ligado até o utilizador desligar explicitamente na app\nconsumoesperto.evolution.session.sticky=${EVOLUTION_SESSION_STICKY:true}\n# Watchdog: deteta sessão fantasma (open na API, Baileys morto) e reinicia instância + webhook\nconsumoesperto.evolution.watchdog.enabled=${EVOLUTION_WATCHDOG_ENABLED:true}\nconsumoesperto.evolution.watchdog.interval-ms=${EVOLUTION_WATCHDOG_INTERVAL_MS:300000}\n# Keepalive (4h): webhook + reconnect leve + presença unavailable — sem restart destrutivo\nconsumoesperto.evolution.keepalive.interval-ms=${EVOLUTION_KEEPALIVE_INTERVAL_MS:14400000}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "7de00d8732adec164dab4ea0c096a989018a5c6a": {"t": 1785752820.800715, "n": 42246, "h": "5347b26aa122e09a", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:false}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:false}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:false}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:false}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:600000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:0}\n# Sessão persistente: mantém ligado até o utilizador desligar explicitamente na app\nconsumoesperto.evolution.session.sticky=${EVOLUTION_SESSION_STICKY:true}\n# Watchdog: deteta sessão fantasma (open na API, Baileys morto) e reinicia instância + webhook\nconsumoesperto.evolution.watchdog.enabled=${EVOLUTION_WATCHDOG_ENABLED:true}\nconsumoesperto.evolution.watchdog.interval-ms=${EVOLUTION_WATCHDOG_INTERVAL_MS:300000}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "e680757c0272520236271c9d6b60aab041576cb2": {"t": 1785752824.1635416, "n": 41829, "h": "3c785a2b625a3068", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:true}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:true}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:true}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:true}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:60000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:60000}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# WEBHOOKS EXTERNOS (MacroDroid / automações)\n# =============================================================================\nconsumoesperto.webhook.notificacoes-celular.token=${WEBHOOK_NOTIFICACOES_CELULAR_TOKEN:}\nconsumoesperto.extrator.webhook-token=${EXTRATOR_WEBHOOK_TOKEN:}\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "abce4a5654e485736fb5028fdbf7da1fd5ce29f4": {"t": 1785752826.2743227, "n": 41468, "h": "490d42e0672b81f7", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:true}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:true}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:true}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:true}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:60000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:60000}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\n# PDF/fatura: extração IA pode levar vários minutos — alinhar com consumoesperto.ai.http.read-timeout-ms\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão (importação PDF + IA)\nserver.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:360000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:360000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "4a2b0198758f534e584c2a280a462a94426edc40": {"t": 1785752827.7113652, "n": 41249, "h": "e355bd5b1f23ad47", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:true}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:true}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:true}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:true}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:60000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:60000}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\nconsumoesperto.ai.http.connect-timeout-ms=${AI_HTTP_CONNECT_TIMEOUT_MS:15000}\nconsumoesperto.ai.http.read-timeout-ms=${AI_HTTP_READ_TIMEOUT_MS:300000}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:20000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:20000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão\nserver.connection-timeout=20000\nserver.tomcat.connection-timeout=20000\nserver.tomcat.keep-alive-timeout=20000\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "90b8365c99c31607360c5c33afa057d37aa2afe1": {"t": 1785752829.3870208, "n": 41098, "h": "f5c6b2f3c5ecdae9", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:true}\nconsumoesperto.evolution.privacy.restart-on-connect=${EVOLUTION_PRIVACY_RESTART_ON_CONNECT:true}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:true}\nconsumoesperto.evolution.privacy.presence-refresh-enabled=${EVOLUTION_PRIVACY_PRESENCE_REFRESH:true}\nconsumoesperto.evolution.privacy.presence-refresh-interval-ms=${EVOLUTION_PRIVACY_PRESENCE_INTERVAL_MS:60000}\nconsumoesperto.evolution.privacy.presence-refresh-after-message-ms=${EVOLUTION_PRIVACY_PRESENCE_AFTER_MSG_MS:60000}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:20000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:20000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão\nserver.connection-timeout=20000\nserver.tomcat.connection-timeout=20000\nserver.tomcat.keep-alive-timeout=20000\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "2cb0eb3a4c7c9c1423a8606f55a28aa4ccf88ba5": {"t": 1785752831.3037639, "n": 40674, "h": "8d87327ab88586d5", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Se ce-u* não gera QR, usa ConsumoEsperto (mesmo QR do Evolution Manager).\nconsumoesperto.evolution.pairing.fallback-to-global-instance=${EVOLUTION_PAIRING_FALLBACK_GLOBAL:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:true}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:true}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:20000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:20000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão\nserver.connection-timeout=20000\nserver.tomcat.connection-timeout=20000\nserver.tomcat.keep-alive-timeout=20000\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "5e4c37992df38c3f381843f3ad9a4c29e753ab82": {"t": 1785752832.8036473, "n": 40495, "h": "38bf3d6e52a1c1c0", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:true}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:true}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# AI Gateway — ATS obrigatório antes de qualquer provider (estratégia AUTO)\nconsumoesperto.ai.gateway.enabled=${AI_GATEWAY_ENABLED:true}\nconsumoesperto.ai.gateway.auto-strategy=${AI_GATEWAY_AUTO_STRATEGY:true}\nconsumoesperto.ai.gateway.default-strategy=${AI_GATEWAY_DEFAULT_STRATEGY:balanced}\nconsumoesperto.ai.gateway.max-retries=${AI_GATEWAY_MAX_RETRIES:2}\nconsumoesperto.ai.gateway.cache-ttl-ms=${AI_GATEWAY_CACHE_TTL_MS:300000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:20000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:20000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão\nserver.connection-timeout=20000\nserver.tomcat.connection-timeout=20000\nserver.tomcat.keep-alive-timeout=20000\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "cbfc74e74c701ba0e1684376951852b027c0e224": {"t": 1785752834.4921668, "n": 40062, "h": "d377baf435c31f17", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:${EVOLUTION_PUBLISH:}}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${consumoesperto.evolution.public-url:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:true}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:true}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:20000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:20000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão\nserver.connection-timeout=20000\nserver.tomcat.connection-timeout=20000\nserver.tomcat.keep-alive-timeout=20000\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "272d15cfe0e1d30d28a2d2b605f9b431a45407c3": {"t": 1785752836.493146, "n": 40026, "h": "8f0b1928aa28ddef", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# URL pública da Evolution (SERVER_URL no contentor). Obrigatória para QR via REST — não confundir com o frontend.\nconsumoesperto.evolution.public-url=${EVOLUTION_SERVER_URL:}\nconsumoesperto.evolution.manager-url=${EVOLUTION_MANAGER_URL:${EVOLUTION_SERVER_URL:http://127.0.0.1:8585}/manager}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:true}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:true}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:20000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:20000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão\nserver.connection-timeout=20000\nserver.tomcat.connection-timeout=20000\nserver.tomcat.keep-alive-timeout=20000\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "993864f205c160295b719492436ce81ef4d76014": {"t": 1785752838.0629947, "n": 39734, "h": "29b43f58e5c5fd88", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\nconsumoesperto.evolution.privacy.restart-after-apply=${EVOLUTION_PRIVACY_RESTART_AFTER_APPLY:true}\nconsumoesperto.evolution.privacy.set-unavailable-presence=${EVOLUTION_PRIVACY_SET_UNAVAILABLE:true}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:20000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:20000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão\nserver.connection-timeout=20000\nserver.tomcat.connection-timeout=20000\nserver.tomcat.keep-alive-timeout=20000\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "7266b64aa73a0b02c09d51d22238a50b25093211": {"t": 1785752839.5532403, "n": 39535, "h": "14d672c6fe7d95e8", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n# Modo fantasma Evolution (Baileys): evita online 24h e leitura automática que silencia notificações no telemóvel\nconsumoesperto.evolution.privacy.always-online=${EVOLUTION_ALWAYS_ONLINE:false}\nconsumoesperto.evolution.privacy.read-messages=${EVOLUTION_READ_MESSAGES:false}\nconsumoesperto.evolution.privacy.read-status=${EVOLUTION_READ_STATUS:false}\nconsumoesperto.evolution.privacy.sync-full-history=${EVOLUTION_SYNC_FULL_HISTORY:false}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:20000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:20000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão\nserver.connection-timeout=20000\nserver.tomcat.connection-timeout=20000\nserver.tomcat.keep-alive-timeout=20000\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "905cbed8d53b51d4536b6c24c268eeea67506bfb": {"t": 1785752841.0262597, "n": 39097, "h": "150a9a3794a7aefe", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\nconsumoesperto.ai.platform-claude-api-key=${CLAUDE_API_KEY:}\nconsumoesperto.ai.claude-base-url=${CLAUDE_BASE_URL:https://api.anthropic.com}\nconsumoesperto.ai.claude-model=${CLAUDE_MODEL:claude-3-5-haiku-20241022}\nconsumoesperto.ai.platform-deepseek-api-key=${DEEPSEEK_API_KEY:}\nconsumoesperto.ai.deepseek-base-url=${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1}\nconsumoesperto.ai.deepseek-model=${DEEPSEEK_MODEL:deepseek-chat}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\n\n# AI Token Suppressor — otimiza prompts antes do fallback Groq→OpenAI→Claude→Gemini→DeepSeek→Ollama\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:0}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:20000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:20000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão\nserver.connection-timeout=20000\nserver.tomcat.connection-timeout=20000\nserver.tomcat.keep-alive-timeout=20000\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "970ca34100ce59e517907f49ab95204dddb12a92": {"t": 1785752842.6946437, "n": 38671, "h": "a58584386bac74f2", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\n\n# AI Token Suppressor — otimiza prompts antes de Groq/Gemini/OpenAI (importação PDF, JARVIS, etc.)\nconsumoesperto.ai.token-suppressor.enabled=${TOKEN_SUPPRESSOR_ENABLED:false}\nconsumoesperto.ai.token-suppressor.base-url=${TOKEN_SUPPRESSOR_URL:}\nconsumoesperto.ai.token-suppressor.api-key=${TOKEN_SUPPRESSOR_API_KEY:}\nconsumoesperto.ai.token-suppressor.strategy=${TOKEN_SUPPRESSOR_STRATEGY:balanced}\nconsumoesperto.ai.token-suppressor.min-chars=${TOKEN_SUPPRESSOR_MIN_CHARS:1200}\nconsumoesperto.ai.token-suppressor.timeout-ms=${TOKEN_SUPPRESSOR_TIMEOUT_MS:120000}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:20000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:20000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão\nserver.connection-timeout=20000\nserver.tomcat.connection-timeout=20000\nserver.tomcat.keep-alive-timeout=20000\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "abc1231f8e481dd1fc142caf6dbdc777feffabab": {"t": 1785752844.208366, "n": 38107, "h": "cd2f6d7c444b7e82", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\n# Modelo Groq mais leve para extração de PDF (menos tokens/dia que llama-3.3-70b).\nconsumoesperto.ai.groq-model-document=${GROQ_MODEL_DOCUMENT:llama-3.1-8b-instant}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:20000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:20000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão\nserver.connection-timeout=20000\nserver.tomcat.connection-timeout=20000\nserver.tomcat.keep-alive-timeout=20000\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "e52ae8bfb7602e05d61030fb8d8c97bab96a5539": {"t": 1785752846.4271965, "n": 37942, "h": "ec8294396c95c36b", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n# Webhook por instância (POST /webhook/set/{instance}); no Docker use o serviço backend interno.\nconsumoesperto.evolution.webhook.url=${EVOLUTION_WEBHOOK_BACKEND_URL:http://backend:8087/api/public/evolution/webhook}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:20000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:20000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão\nserver.connection-timeout=20000\nserver.tomcat.connection-timeout=20000\nserver.tomcat.keep-alive-timeout=20000\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}, "56e7d62351c35f735e71f20dc1948cc72db5f5ad": {"t": 1785752848.8791358, "n": 37726, "h": "86275be97fa1f4b6", "c": "# =============================================================================\n# CONFIGURAÇÃO CONSOLIDADA - CONSUMO ESPERTO\n# =============================================================================\n# Este arquivo contém TODAS as configurações do projeto em um só lugar\n# =============================================================================\n\n# =============================================================================\n# CONFIGURAÇÕES BÁSICAS DO SPRING BOOT\n# =============================================================================\nserver.port=8080\nspring.application.name=consumo-esperto-backend\n# spring.profiles.active=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES POSTGRESQL\n# =============================================================================\n# IMPORTANTE: Use variáveis de ambiente para credenciais em produção!\n# Exemplo: export DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# export DATABASE_USERNAME=seu_usuario\n# export DATABASE_PASSWORD=sua_senha\nspring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/consumoesperto}\nspring.datasource.username=${DATABASE_USERNAME:bruce}\nspring.datasource.password=${DATABASE_PASSWORD:0301HeX@}\nspring.datasource.driver-class-name=org.postgresql.Driver\n\n# Pool de conexões HikariCP otimizado para PostgreSQL\nspring.datasource.hikari.maximum-pool-size=20\nspring.datasource.hikari.minimum-idle=5\nspring.datasource.hikari.connection-timeout=30000\nspring.datasource.hikari.idle-timeout=600000\nspring.datasource.hikari.max-lifetime=1800000\nspring.datasource.hikari.validation-timeout=5000\nspring.datasource.hikari.leak-detection-threshold=60000\nspring.datasource.hikari.connection-test-query=SELECT 1\nspring.datasource.hikari.validation-interval=30000\n\n# Configurações críticas para transações Hibernate\nspring.datasource.hikari.auto-commit=false\nspring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED\n\n# Configuração JPA/Hibernate para PostgreSQL\nspring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect\nspring.jpa.hibernate.ddl-auto=none\nspring.jpa.hibernate.ddl-auto-validate=false\nspring.jpa.hibernate.ddl-auto-validate-schema=false\nspring.jpa.hibernate.ddl-auto-validate-constraints=false\nspring.jpa.hibernate.ddl-auto-validate-foreign-keys=false\nspring.jpa.show-sql=true\nspring.jpa.properties.hibernate.hbm2ddl.auto=none\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate=false\nspring.jpa.properties.hibernate.hbm2ddl.auto-validate-schema=false\nspring.jpa.properties.hibernate.format_sql=true\nspring.jpa.properties.hibernate.use_sql_comments=true\n\n# Otimizações de performance PostgreSQL\nspring.jpa.properties.hibernate.jdbc.batch_size=20\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\n\n# =============================================================================\n# CONFIGURAÇÕES FLYWAY (MIGRAÇÕES DE BANCO)\n# =============================================================================\nspring.flyway.enabled=true\nspring.flyway.locations=classpath:db/migration\nspring.flyway.baseline-on-migrate=true\nspring.flyway.baseline-version=0\nspring.flyway.validate-on-migrate=false\nspring.flyway.clean-disabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE (CAFFEINE)\n# =============================================================================\nspring.cache.type=caffeine\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes\nspring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=1h\n\n# Estratégia de fatura (fechamento estimado = vencimento − N dias corridos; ajuste por banco se necessário)\nconsumoesperto.fatura.dias-entre-fechamento-e-vencimento=10\n\n# Watcher (The Watcher): notificação WhatsApp no dia estimado de fechamento (uma vez por ciclo)\nconsumoesperto.watcher.fechamento.enabled=true\nconsumoesperto.watcher.fechamento.cron=0 0 9 * * *\nconsumoesperto.watcher.zone=America/Sao_Paulo\n\n# =============================================================================\n# CONFIGURAÇÕES DE OBSERVABILIDADE (MICROMETER + PROMETHEUS) - PRODUÇÃO\n# =============================================================================\n# Endpoints de monitoramento\nmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,env,configprops,beans,loggers\nmanagement.endpoint.health.show-details=${HEALTH_DETAILS:when-authorized}\nmanagement.endpoint.health.show-components=always\nmanagement.endpoint.info.enabled=true\nmanagement.endpoint.metrics.enabled=true\nmanagement.endpoint.prometheus.enabled=true\n\n# Configurações de métricas\nmanagement.metrics.export.prometheus.enabled=true\nmanagement.metrics.tags.application=${spring.application.name}\nmanagement.metrics.tags.environment=${SPRING_PROFILES_ACTIVE:prod}\nmanagement.metrics.tags.version=${APP_VERSION:1.0.0}\nmanagement.metrics.tags.instance=${HOSTNAME:localhost}\n\n# Configurações de saúde customizadas\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\nmanagement.health.circuitbreakers.enabled=true\nmanagement.health.redis.enabled=false\n\n# Configurações de métricas JVM\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\nmanagement.metrics.enable.web=true\nmanagement.metrics.enable.cache=true\n\n# Configurações de tracing distribuído\nmanagement.tracing.sampling.probability=${TRACING_SAMPLING_PROBABILITY:0.1}\nmanagement.tracing.zipkin.endpoint=${ZIPKIN_ENDPOINT:}\nmanagement.tracing.jaeger.endpoint=${JAEGER_ENDPOINT:}\n\n# =============================================================================\n# CONFIGURAÇÕES GERAIS\n# =============================================================================\nspring.main.allow-bean-definition-overriding=true\nspring.main.allow-circular-references=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA (JWT + OAUTH2) - PRODUÇÃO\n# =============================================================================\n# Configurações JWT - PRODUÇÃO (OBRIGATÃ\"RIO: USE VARIÁVEIS DE AMBIENTE!)\n# IMPORTANTE: Configure uma chave secreta forte via variável de ambiente em produção!\n# export JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\njwt.secret=${JWT_SECRET:minha_chave_secreta_jwt_super_segura_para_consumo_esperto_2024_producao}\njwt.expiration=${JWT_EXPIRATION:86400000}\njwt.refresh-expiration=${JWT_REFRESH_EXPIRATION:604800000}\njwt.issuer=${JWT_ISSUER:consumo-esperto-api}\njwt.audience=${JWT_AUDIENCE:consumo-esperto-client}\n\n# Configurações OAuth2 Resource Server\nspring.security.oauth2.resourceserver.jwt.issuer-uri=${JWT_ISSUER_URI:${ngrok.url}}\nspring.security.oauth2.resourceserver.jwt.jwk-set-uri=${JWT_JWK_SET_URI:${ngrok.url}/.well-known/jwks.json}\n\n# Google OAuth2 - Produção\n# IMPORTANTE: Configure via variáveis de ambiente em produção!\n# export GOOGLE_CLIENT_ID=seu_client_id\n# export GOOGLE_CLIENT_SECRET=seu_client_secret\n# OAuth2 Client - use \"disabled\" como padrão para evitar erro de validação\n# Configure GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET via variáveis de ambiente para habilitar\nspring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:disabled}\nspring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:disabled}\n# Escopos públicos apenas (openid, profile, email) — tratamento/J.A.R.V.I.S. definido pelo utilizador na app.\nspring.security.oauth2.client.registration.google.scope=openid,profile,email\nspring.security.oauth2.client.registration.google.redirect-uri=http://localhost:14200/api/auth/google/callback\nspring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code\nspring.security.oauth2.client.registration.google.client-name=Google\n\n# Configurações OAuth2 Provider\nspring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth\nspring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token\nspring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo\nspring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis.com/oauth2/v3/certs\n\n# =============================================================================\n# CONFIGURAÇÕES RESILIENCE4J (CIRCUIT BREAKER + RETRY)\n# =============================================================================\nresilience4j.circuitbreaker.instances.default.sliding-window-size=10\nresilience4j.circuitbreaker.instances.default.failure-rate-threshold=50\nresilience4j.circuitbreaker.instances.default.wait-duration-in-open-state=30s\nresilience4j.circuitbreaker.instances.default.permitted-number-of-calls-in-half-open-state=5\n\n# =============================================================================\n# CONFIGURAÇÕES OPENAPI 3.0 (SPRINGDOC)\n# =============================================================================\nspringdoc.api-docs.path=/api-docs\nspringdoc.swagger-ui.path=/swagger-ui.html\nspringdoc.swagger-ui.operations-sorter=method\nspringdoc.swagger-ui.tags-sorter=alpha\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO - PRODUÇÃO\n# =============================================================================\n# Padrões de log estruturado para produção\nlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\nlogging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n\n\n# Configuração de arquivo de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\nlogging.logback.rollingpolicy.total-size-cap=1GB\n\n# Logs específicos para PostgreSQL e Hibernate - PRODUÇÃO\nlogging.level.org.hibernate.SQL=${HIBERNATE_SQL_LOG_LEVEL:WARN}\nlogging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LOG_LEVEL:WARN}\nlogging.level.com.zaxxer.hikari=${HIKARI_LOG_LEVEL:WARN}\nlogging.level.org.springframework.jdbc=${SPRING_JDBC_LOG_LEVEL:WARN}\n\n# Logs da aplicação - PRODUÇÃO\nlogging.level.com.consumoesperto=${APP_LOG_LEVEL:INFO}\nlogging.level.org.springframework.security=${SECURITY_LOG_LEVEL:WARN}\nlogging.level.org.springframework.web=${WEB_LOG_LEVEL:WARN}\nlogging.level.org.springframework.boot=${BOOT_LOG_LEVEL:WARN}\n\n# Logs de auditoria\nlogging.level.com.consumoesperto.security=${AUDIT_LOG_LEVEL:INFO}\nlogging.level.com.consumoesperto.controller=${CONTROLLER_LOG_LEVEL:INFO}\n\n# =============================================================================\n# CONFIGURAÇÕES DE CORS\n# =============================================================================\n# Padrões permitidos pelo SecurityConfig (origins, vírgula). Produção: use profile prod + CORS_ALLOWED_PATTERNS.\n# Produção: defina CORS_ALLOWED_PATTERNS com a origem exata do Angular (scheme+host+porta). Veja também SecurityConfig javadoc.\napp.security.cors-allowed-patterns=${CORS_ALLOWED_PATTERNS:http://localhost:14200,http://localhost:4200,http://localhost:8181,https://consumoesperto.brucew07.com.br,https://consumoesperto.brucew07.com.br:8443,https://consumoesperto.brucew07.com.br:*,http://consumoesperto.brucew07.com.br:*,https://*.ngrok-free.app,https://*.ngrok.io}\napp.frontend-base-url=${APP_FRONTEND_BASE_URL:http://localhost:14200}\n# Callback OAuth Google Calendar (Cronos). Registre o mesmo URI no Google Cloud Console > Credenciais > URI de redirecionamento.\nconsumoesperto.google.calendar.redirect-uri=${GOOGLE_CALENDAR_REDIRECT_URI:http://localhost:18081/api/integracoes/google-calendar/oauth2/callback}\ncors.allowed-origins=https://*.ngrok-free.app,https://79c3d0af5801.ngrok-free.app,http://localhost:14200,http://localhost:4200,http://localhost:3000\ncors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS\ncors.allowed-headers=Authorization,Content-Type,X-Requested-With\ncors.allow-credentials=true\n\n# =============================================================================\n# CONFIGURAÇÕES NGROK (URL PÚBLICA)\n# =============================================================================\n# IMPORTANTE: Configure via variável de ambiente ou use detecção automática\n# export NGROK_URL=https://seu-dominio.ngrok-free.app\nngrok.url=${NGROK_URL:https://79c3d0af5801.ngrok-free.app}\nngrok.auto-detect=${NGROK_AUTO_DETECT:true}\nngrok.api-url=${NGROK_API_URL:http://localhost:14040/api/tunnels}\n\n# Evolution API (WhatsApp provider)\n# Em producao defina EVOLUTION_APIKEY / EVOLUTION_INSTANCE. Padrao local alinha ao script scripts/sincronizar-evolution-env.ps1; opcionalmente o script gera backend/config/application-dev-evolution-secrets.properties (import no fim deste ficheiro).\nevolution.url=${EVOLUTION_URL:http://localhost:18080}\n# Compose/Docker pode usar EVOLUTION_API_KEY; dev local costuma usar EVOLUTION_APIKEY (alias sem underscore).\nevolution.apikey=${EVOLUTION_APIKEY:${EVOLUTION_API_KEY:}}\nevolution.instance=${EVOLUTION_INSTANCE:ConsumoEsperto}\n# Pareamento WhatsApp (GET /instance/connect): a Evolution aguarda ~2 s após connect quando state=close; valores por defeito dão várias tentativas longas.\nevolution.pairing.connect.retries=${EVOLUTION_PAIRING_CONNECT_RETRIES:6}\nevolution.pairing.connect.pauseMs=${EVOLUTION_PAIRING_CONNECT_PAUSE_MS:4000}\n# Cache curto credenciais + numero no polling do modal (reduz Hibernate). LOG_WEAK: diagnostico quando REST não traz QR.\nevolution.pairing.cred-cache-ms=${EVOLUTION_PAIRING_CRED_CACHE_MS:25000}\nevolution.pairing.log-weak-response=${EVOLUTION_PAIRING_LOG_WEAK:false}\n# Uma instância Evolution por utilizador (ce-u{id}) — evita conflito de QR/sessão na instância global ConsumoEsperto.\nconsumoesperto.evolution.dedicated-instance-per-user=${EVOLUTION_DEDICATED_INSTANCE_PER_USER:true}\n\n# Criar Utilizador + UsuarioAiConfig ao receber mensagem de número novo (multi-tenant WhatsApp).\nconsumoesperto.whatsapp.auto-provision-user=${WHATSAPP_AUTO_PROVISION_USER:false}\n\n# Webhook Evolution/Twilio: só números autorizados e modo estrito multi-inquilino\nconsumoesperto.whatsapp.only-respond-owner=${ONLY_RESPOND_OWNER:false}\nconsumoesperto.whatsapp.authorized-numbers-extra=${WHATSAPP_AUTHORIZED_EXTRA:}\nconsumoesperto.whatsapp.owner-phone-global=${WHATSAPP_OWNER_PHONE:}\n\n# Chave Groq da plataforma (fallback quando o utilizador não tem chave própria).\nconsumoesperto.ai.platform-groq-api-key=${GROQ_API_KEY:}\nconsumoesperto.ai.platform-openai-api-key=${OPENAI_API_KEY:}\nconsumoesperto.ai.platform-ollama-base-url=${OLLAMA_BASE_URL:}\nconsumoesperto.ai.platform-ollama-model=${OLLAMA_MODEL:llama3.2}\nconsumoesperto.ai.embedding-model=${OPENAI_EMBEDDING_MODEL:text-embedding-3-small}\nconsumoesperto.ai.platform-gemini-api-key=${GEMINI_API_KEY:}\nconsumoesperto.ai.gemini-base-url=${GEMINI_BASE_URL:https://generativelanguage.googleapis.com/v1beta}\nconsumoesperto.ai.gemini-model=${GEMINI_MODEL:gemini-2.5-flash}\n\n# Persona J.A.R.V.I.S. (opcional): vocativo fixo e tom britânico nos prompts de IA\nconsumoesperto.jarvis.force-vocative=${JARVIS_FORCE_VOCATIVE:}\nconsumoesperto.jarvis.british-persona=${JARVIS_BRITISH_PERSONA:false}\n# Resposta em voz (ElevenLabs) após texto no WhatsApp/Evolution — requer elevenlabs.*\nconsumoesperto.jarvis.whatsapp-voice-reply=${JARVIS_WHATSAPP_VOICE_REPLY:false}\n# Minutos entre alertas WhatsApp de risco (Sentinela/previsão); 0 = sem limite\nconsumoesperto.jarvis.alerta-risco-cooldown-minutes=${JARVIS_ALERTA_RISCO_COOLDOWN_MINUTES:30}\n\n# ElevenLabs — Protocolo Áudio (TTS) para PTT no WhatsApp\nelevenlabs.api-key=${ELEVENLABS_API_KEY:}\nelevenlabs.voice-id=${ELEVENLABS_VOICE_ID:}\nelevenlabs.model-id=${ELEVENLABS_MODEL_ID:eleven_multilingual_v2}\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRANSAÇÕES\n# =============================================================================\nspring.transaction.default-timeout=30s\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT (MAPEAMENTO DTO-ENTITY)\n# =============================================================================\nmapstruct.default-component-model=spring\nmapstruct.unmapped-target-policy=IGNORE\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO\n# =============================================================================\nspring.devtools.restart.enabled=true\nspring.devtools.livereload.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TESTE\n# =============================================================================\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE PRODUÇÃO\n# =============================================================================\n# Estas configurações são sobrescritas pelo profile 'prod'\nspring.jpa.hibernate.ddl-auto=none\nspring.flyway.enabled=true\n# logging.level.root=WARN\n# logging.level.com.consumoesperto=INFO\n\n# =============================================================================\n# CONFIGURAÇÕES DE DESENVOLVIMENTO LOCAL\n# =============================================================================\n# Para uso com PostgreSQL local:\n# spring.datasource.url=jdbc:postgresql://localhost:5432/consumo_esperto\n# spring.datasource.username=consumo_esperto\n# spring.datasource.password=consumo_esperto123\n\n# =============================================================================\n# CONFIGURAÇÕES DE BACKUP E MONITORAMENTO\n# =============================================================================\n# Backup automático (opcional)\n# backup.enabled=true\n# backup.schedule=0 0 2 * * ? # 2h da manhã\n# backup.retention-days=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE PERFORMANCE - PRODUÇÃO\n# =============================================================================\n# Thread pool para operações assíncronas\nspring.task.execution.pool.core-size=${THREAD_POOL_CORE_SIZE:10}\nspring.task.execution.pool.max-size=${THREAD_POOL_MAX_SIZE:50}\nspring.task.execution.pool.queue-capacity=${THREAD_POOL_QUEUE_CAPACITY:200}\nspring.task.execution.pool.keep-alive=${THREAD_POOL_KEEP_ALIVE:60s}\nspring.task.execution.thread-name-prefix=async-exec-\n\n# Configurações de servidor Tomcat otimizadas\nserver.tomcat.threads.max=${TOMCAT_MAX_THREADS:200}\nserver.tomcat.threads.min-spare=${TOMCAT_MIN_THREADS:10}\nserver.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:8192}\nserver.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:100}\nserver.tomcat.connection-timeout=${TOMCAT_CONNECTION_TIMEOUT:20000}\nserver.tomcat.keep-alive-timeout=${TOMCAT_KEEP_ALIVE_TIMEOUT:20000}\nserver.tomcat.max-keep-alive-requests=${TOMCAT_MAX_KEEP_ALIVE_REQUESTS:100}\n\n# Configurações de compressão otimizadas\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml\nserver.compression.min-response-size=${COMPRESSION_MIN_SIZE:1024}\nserver.compression.excluded-user-agents=.*bot.*,.*spider.*,.*crawler.*\n\n# Configurações de cache otimizadas\nspring.cache.caffeine.spec=${CACHE_SPEC:maximumSize=2000,expireAfterWrite=2h,recordStats}\nspring.cache.cache-names=usuarios,cartoes,faturas,transacoes,relatorios,simulacoes\n\n# Configurações de Hibernate otimizadas\nspring.jpa.properties.hibernate.jdbc.batch_size=${HIBERNATE_BATCH_SIZE:25}\nspring.jpa.properties.hibernate.order_inserts=true\nspring.jpa.properties.hibernate.order_updates=true\nspring.jpa.properties.hibernate.jdbc.batch_versioned_data=true\nspring.jpa.properties.hibernate.connection.provider_disables_autocommit=true\nspring.jpa.properties.hibernate.cache.use_second_level_cache=false\nspring.jpa.properties.hibernate.cache.use_query_cache=false\n# spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory\n\n# Configurações de pool de conexões otimizadas\nspring.datasource.hikari.maximum-pool-size=${HIKARI_MAX_POOL_SIZE:20}\nspring.datasource.hikari.minimum-idle=${HIKARI_MIN_IDLE:5}\nspring.datasource.hikari.connection-timeout=${HIKARI_CONNECTION_TIMEOUT:30000}\nspring.datasource.hikari.idle-timeout=${HIKARI_IDLE_TIMEOUT:600000}\nspring.datasource.hikari.max-lifetime=${HIKARI_MAX_LIFETIME:1800000}\nspring.datasource.hikari.validation-timeout=${HIKARI_VALIDATION_TIMEOUT:5000}\nspring.datasource.hikari.leak-detection-threshold=${HIKARI_LEAK_DETECTION:60000}\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA ADICIONAIS\n# =============================================================================\n# Rate limiting ATIVADO\nrate.limit.enabled=true\nrate.limit.requests-per-minute=100\nrate.limit.burst-capacity=200\n\n# Configuração para evitar loops de autenticação em endpoints de erro\nserver.error.include-message=always\nserver.error.include-binding-errors=always\nserver.error.include-stacktrace=never\nserver.error.include-exception=false\n\n# Headers de segurança HTTP\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=${COOKIE_SECURE:false}\nserver.servlet.session.cookie.same-site=strict\nserver.servlet.session.cookie.max-age=3600\n\n# Headers de segurança adicionais\nsecurity.headers.content-security-policy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none'\nsecurity.headers.strict-transport-security=max-age=31536000; includeSubDomains\nsecurity.headers.x-frame-options=DENY\nsecurity.headers.x-content-type-options=nosniff\nsecurity.headers.x-xss-protection=1; mode=block\nsecurity.headers.referrer-policy=strict-origin-when-cross-origin\nsecurity.headers.permissions-policy=geolocation=(), microphone=(), camera=()\n\n# Configurações de auditoria de segurança\nsecurity.audit.enabled=true\nsecurity.audit.log-failed-logins=true\nsecurity.audit.log-successful-logins=true\nsecurity.audit.log-sensitive-operations=true\n\n# Configurações de bloqueio de conta\nsecurity.account.lockout.max-attempts=5\nsecurity.account.lockout.duration=15m\nsecurity.account.lockout.threshold=3\n\n# Configurações de sessão\nsecurity.session.max-sessions=1\nsecurity.session.invalidate-on-expiration=true\nsecurity.session.fixation-protection=changeSessionId\n\n# Configurações de senha\nsecurity.password.min-length=8\nsecurity.password.require-uppercase=true\nsecurity.password.require-lowercase=true\nsecurity.password.require-numbers=true\nsecurity.password.require-special-chars=true\nsecurity.password.history-size=5\n\n# Configurações de token\nsecurity.token.refresh-token.enabled=true\nsecurity.token.refresh-token.expiration=604800000\nsecurity.token.blacklist.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE AUDITORIA\n# =============================================================================\n# Auditoria automática de entidades\nspring.data.jpa.repositories.enabled=true\nspring.jpa.properties.hibernate.jdbc.time_zone=UTC\n\n# =============================================================================\n# CONFIGURAÇÕES DE VALIDAÇÃO\n# =============================================================================\n# Validação de beans\nspring.validation.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO\n# =============================================================================\n# Compressão de resposta HTTP\nserver.compression.enabled=true\nserver.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json\nserver.compression.min-response-size=1024\n\n# =============================================================================\n# CONFIGURAÇÕES DE SESSION\n# =============================================================================\n# Configuração de sessão\nserver.servlet.session.timeout=30m\nserver.servlet.session.cookie.http-only=true\nserver.servlet.session.cookie.secure=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE HEADERS DE SEGURANÇA\n# =============================================================================\n# Headers de segurança HTTP\nserver.servlet.session.cookie.same-site=strict\n\n# =============================================================================\n# CONFIGURAÇÕES DE TIMEOUT\n# =============================================================================\n# Timeouts de conexão\nserver.connection-timeout=20000\nserver.tomcat.connection-timeout=20000\nserver.tomcat.keep-alive-timeout=20000\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO DE LOGS\n# =============================================================================\n# Compressão de arquivos de log\nlogging.file.name=logs/consumo-esperto.log\nlogging.logback.rollingpolicy.max-file-size=100MB\nlogging.logback.rollingpolicy.max-history=30\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE SAÚDE\n# =============================================================================\n# Endpoints de saúde customizados\nmanagement.health.db.enabled=true\nmanagement.health.diskspace.enabled=true\nmanagement.health.defaults.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MÉTRICAS PERSONALIZADAS\n# =============================================================================\n# Métricas customizadas\nmanagement.metrics.enable.jvm=true\nmanagement.metrics.enable.process=true\nmanagement.metrics.enable.system=true\nmanagement.metrics.enable.logback=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DISTRIBUÍDO (OPCIONAL)\n# =============================================================================\n# Para cache distribuído com Redis (opcional)\n# spring.cache.type=redis\n# spring.redis.host=localhost\n# spring.redis.port=6379\n\n# =============================================================================\n# CONFIGURAÇÕES DE FILAS ASSÍNCRONAS (OPCIONAL)\n# =============================================================================\n# Para processamento assíncrono com RabbitMQ (opcional)\n# spring.rabbitmq.host=localhost\n# spring.rabbitmq.port=5672\n# spring.rabbitmq.username=guest\n# spring.rabbitmq.password=guest\n\n# =============================================================================\n# CONFIGURAÇÕES DE EMAIL (OPCIONAL)\n# =============================================================================\n# Para envio de emails (opcional)\n# spring.mail.host=smtp.gmail.com\n# spring.mail.port=587\n# spring.mail.username=${EMAIL_USERNAME:}\n# spring.mail.password=${EMAIL_PASSWORD:}\n# spring.mail.properties.mail.smtp.auth=true\n# spring.mail.properties.mail.smtp.starttls.enable=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE ARQUIVOS (OPCIONAL)\n# =============================================================================\n# Para upload de arquivos (opcional)\n# spring.servlet.multipart.max-file-size=10MB\n# spring.servlet.multipart.max-request-size=10MB\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE DE CONFIGURAÇÃO (OPCIONAL)\n# =============================================================================\n# Para cache de configuração com Spring Cloud Config (opcional)\n# spring.cloud.config.uri=http://localhost:8888\n# spring.cloud.config.fail-fast=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE SERVICE DISCOVERY (OPCIONAL)\n# =============================================================================\n# Para service discovery com Eureka (opcional)\n# eureka.client.service-url.defaultZone=http://localhost:8761/eureka/\n# eureka.instance.prefer-ip-address=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOAD BALANCER (OPCIONAL)\n# =============================================================================\n# Para load balancing com Spring Cloud LoadBalancer (opcional)\n# spring.cloud.loadbalancer.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE TRACING (OPCIONAL)\n# =============================================================================\n# Para tracing distribuído com Sleuth (opcional)\n# spring.sleuth.sampler.probability=1.0\nspring.sleuth.web.client.enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE CIRCUIT BREAKER GLOBAL (OPCIONAL)\n# =============================================================================\n# Para circuit breaker global com Hystrix (opcional)\n# hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=5000\n# hystrix.command.default.circuitBreaker.requestVolumeThreshold=20\n\n# =============================================================================\n# CONFIGURAÇÕES DE MONITORAMENTO DE PERFORMANCE (OPCIONAL)\n# =============================================================================\n# Para monitoramento de performance com Micrometer (opcional)\n# management.metrics.export.influx.enabled=false\n# management.metrics.export.graphite.enabled=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para logging avançado com Logback (opcional)\n# logging.logback.rollingpolicy.file-name-pattern=logs/consumo-esperto.%d{yyyy-MM-dd}.%i.log.gz\n\n# =============================================================================\n# CONFIGURAÇÕES DE SEGURANÇA AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para segurança avançada (opcional)\n# security.require-ssl=true\n# security.enable-csrf=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE COMPRESSÃO AVANÇADA (OPCIONAL)\n# =============================================================================\n# Para compressão avançada (opcional)\n# server.compression.excluded-user-agents=.*bot.*,.*spider.*\n# server.compression.excluded-paths=/health,/metrics\n\n# =============================================================================\n# CONFIGURAÇÕES DE CACHE AVANÇADO (OPCIONAL)\n# =============================================================================\n# Para cache avançado com Caffeine (opcional)\n# spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=600s,recordStats\n\n# =============================================================================\n# CONFIGURAÇÕES DE RESILIENCE4J AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Resilience4j avançado (opcional)\n# resilience4j.retry.instances.default.max-attempts=3\n# resilience4j.retry.instances.default.wait-duration=100ms\n# resilience4j.retry.instances.default.retry-exceptions=java.io.IOException,java.util.concurrent.TimeoutException\n\n# =============================================================================\n# CONFIGURAÇÕES DE FLYWAY AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Flyway avançado (opcional)\n# spring.flyway.baseline-description=Initial baseline\n# spring.flyway.baseline-on-migrate=true\n# spring.flyway.baseline-version=0\n# spring.flyway.clean-disabled=true\n# spring.flyway.locations=classpath:db/migration\n# spring.flyway.out-of-order=false\n# spring.flyway.validate-on-migrate=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE MAPSTRUCT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para MapStruct avançado (opcional)\n# mapstruct.default-injection-strategy=constructor\n# mapstruct.verbose=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE LOGGING ESTRUTURADO AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para logging estruturado avançado (opcional)\n# logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n# logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n\n\n# =============================================================================\n# CONFIGURAÇÕES DE OPENAPI AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para OpenAPI avançado (opcional)\n# springdoc.swagger-ui.doc-expansion=none\n# springdoc.swagger-ui.filter=true\n# springdoc.swagger-ui.try-it-out-enabled=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE JWT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para JWT avançado (opcional)\n# jwt.secret=${JWT_SECRET:your-secret-key}\n# jwt.expiration=${JWT_EXPIRATION:86400000}\n\n# =============================================================================\n# CONFIGURAÇÕES AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para configurações avançadas (opcional)\n# spring.jpa.properties.hibernate.connection.charSet=UTF-8\n# spring.jpa.properties.hibernate.connection.characterEncoding=UTF-8\n# spring.jpa.properties.hibernate.connection.useUnicode=true\n\n# =============================================================================\n# CONFIGURAÇÕES DE HIKARICP AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para HikariCP avançado (opcional)\n# spring.datasource.hikari.connection-init-sql=SELECT 1\n# spring.datasource.hikari.connection-test-query=SELECT 1\n# spring.datasource.hikari.initialization-fail-timeout=1\n# spring.datasource.hikari.validation-timeout=3000\n\n# =============================================================================\n# CONFIGURAÇÕES DE SPRING BOOT AVANÇADAS (OPCIONAL)\n# =============================================================================\n# Para Spring Boot avançado (opcional)\n# spring.main.allow-bean-definition-overriding=true\n# spring.main.allow-circular-references=true\n# spring.main.lazy-initialization=false\n\n# =============================================================================\n# CONFIGURAÇÕES DE PROFILE ESPECÍFICAS\n# =============================================================================\n# Estas configurações são sobrescritas pelos profiles específicos\n\n# Profile: dev\n# spring.profiles.include=dev\n\n# Profile: prod\n# spring.profiles.include=prod\n\n# Profile: test\n# spring.profiles.include=test\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE\n# =============================================================================\n# Configurações baseadas no ambiente\n# spring.config.activate.on-profile=consolidated\n\n# =============================================================================\n# CONFIGURAÇÕES DE AMBIENTE E VARIÁVEIS OBRIGATÓRIAS\n# =============================================================================\n# IMPORTANTE: Configure as seguintes variáveis de ambiente em produção:\n\n# Banco de dados\n# DATABASE_URL=jdbc:postgresql://localhost:5432/consumoesperto\n# DATABASE_USERNAME=seu_usuario\n# DATABASE_PASSWORD=sua_senha_segura\n\n# JWT e Segurança\n# JWT_SECRET=sua_chave_secreta_super_segura_de_pelo_menos_256_bits\n# JWT_EXPIRATION=86400000\n# JWT_REFRESH_EXPIRATION=604800000\n\n# Google OAuth2\n# GOOGLE_CLIENT_ID=seu_google_client_id\n# GOOGLE_CLIENT_SECRET=seu_google_client_secret\n\n# URL Pública (ngrok ou domínio)\n# NGROK_URL=https://seu-dominio.com\n\n# Configurações de Performance (opcional)\n# THREAD_POOL_CORE_SIZE=10\n# THREAD_POOL_MAX_SIZE=50\n# HIKARI_MAX_POOL_SIZE=20\n# CACHE_SPEC=maximumSize=2000,expireAfterWrite=2h,recordStats\n\n# Configurações de Log (opcional)\n# APP_LOG_LEVEL=INFO\n# HIBERNATE_SQL_LOG_LEVEL=WARN\n# SECURITY_LOG_LEVEL=WARN\n\n# =============================================================================\n# CONFIGURAÇÕES FINAIS\n# =============================================================================\n# Este arquivo contém todas as configurações necessárias para produção\n# Para usar, configure as variáveis de ambiente acima\n# Para desenvolvimento local, use application-dev.properties\n\n# Evolution: ficheiro opcional (gerado pelo script sincronizar-evolution-env.ps1) sobrescreve evolution.* acima.\nspring.config.import=optional:file:./config/application-dev-evolution-secrets.properties\n\n"}}, "backend/src/main/java/com/consumoesperto/service/OpenAiService.java": {"d9062b816b44ebdda9aa588727ca874a6a156a55": {"t": 1785745701.8127794, "n": 88839, "h": "4aaa4e7f3418681e", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.ResultadoConselho;\nimport com.consumoesperto.model.ResultadoRegistroEmprestimo;\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.model.Veredito;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.jarvis.TratamentoUsuarioService;\nimport com.consumoesperto.service.ai.AiRouterRequestContext;\nimport com.consumoesperto.service.ai.AITaskType;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.service.ai.AiRouterService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.text.NumberFormat;\nimport java.util.ArrayList;\nimport java.util.Locale;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n private final AiRouterService aiRouterService;\n private final TratamentoUsuarioService tratamentoUsuarioService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private com.consumoesperto.service.jarvis.JarvisContextoFinanceiroCacheService jarvisContextoFinanceiroCacheService;\n\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private com.consumoesperto.service.jarvis.JarvisConversaJanelaService jarvisConversaJanelaService;\n\n @Value(\"${consumoesperto.jarvis.performance.parse-read-timeout-ms:25000}\")\n private int parseReadTimeoutMs;\n\n private RestTemplate restTemplate;\n private RestTemplate parseRestTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n\n SimpleClientHttpRequestFactory parseFactory = new SimpleClientHttpRequestFactory();\n parseFactory.setConnectTimeout(Math.max(3_000, Math.min(aiHttpConnectTimeoutMs, 10_000)));\n parseFactory.setReadTimeout(Math.max(5_000, parseReadTimeoutMs));\n parseRestTemplate = new RestTemplate(parseFactory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String tokenHint = filename != null ? filename : \"audio\";\n return executeAIRequestWithFallback(\n AITaskType.VOICE_TRANSCRIPTION,\n userId,\n cfg,\n p -> canTranscribe(cfg, p),\n tokenHint,\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null) {\n return \"\";\n }\n try {\n if (jarvisContextoFinanceiroCacheService != null) {\n return jarvisContextoFinanceiroCacheService.montarBlocoContexto(userId);\n }\n return \"\";\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n return parseCommand(inputText, userId, 0.1);\n }\n\n /** Retry estruturado: use temperatura 0 para resposta determinística. */\n public JsonNode parseCommand(String inputText, Long userId, double temperature) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|CREATE_FIXED_EXPENSE|CREATE_SUBSCRIPTION|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_FINANCIAL_ADVICE|RECORD_CONSIGNMENT_LOAN|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|LIST_FATURAS|LIST_ACCOUNTS|TRANSFER_BETWEEN_ACCOUNTS|LIST_TRANSACTIONS|LIST_CATEGORIES|LIST_METAS|GET_REPORT_SUMMARY|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|SET_INCOME_PROFILE|CONFIRM_FISCAL_PROVISION|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|LIST_SUBSCRIPTIONS|TOGGLE_SUBSCRIPTION|START_TUTORIAL|STOP_TUTORIAL|TUTORIAL_STEP|GREETING|UNKNOWN), \" +\n \"valorTomado (valor recebido do empréstimo consignado), nomeConta (apelido da conta que recebeu), \" +\n \"contaOrigem (apelido/nome da conta de origem na transferência), contaDestino (apelido/nome da conta de destino na transferência), \" +\n \"mes (1-12, opcional — consultas de extrato/orçamento), ano (ex.: 2026, opcional), tipo (DESPESA|RECEITA, opcional — filtro de extrato), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento|despesa_fixa|assinatura), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"subscriptionActive (true para ativar/reativar assinatura cadastrada; false para desativar/pausar), \" +\n \"tipoPerfil (CONTRACHEQUE|RECEBIMENTO_UNICO|FLUXO_DIARIO — perfil híbrido de renda), \" +\n \"salarioBruto, descontosHolerite (total ou use descontosFixos em updates), diaRecebimento (1-31, CONTRACHEQUE), \" +\n \"valorLiquidoFixo, diaRecebimentoFixo (RECEBIMENTO_UNICO), metaFaturamentoMensal (FLUXO_DIARIO), \" +\n \"tipoOperacao (COMPRA_AVISTA|COMPRA_PARCELADA|EMPRESTIMO|CONSIGNADO|FINANCIAMENTO — conselho de compra/empréstimo), \" +\n \"valorTotal (valor do bem ou valor tomado no empréstimo), valorParcela (valor de cada parcela), \" +\n \"quantidadeParcelas (número de parcelas), descricaoItem (ex.: moto, tênis, consignado), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"nome_normalizado (nome corrigido da entidade identificada), entidade_ambigua (true se abreviação dupla), \" +\n \"entidade_desconhecida (true se não reconhecer), opcoes_entidade (array de nomes quando ambíguo), \" +\n \"confianca (0-1), errorMessage, \" +\n \"memoriasSugeridas (array OPCIONAL de memórias de longo prazo dignas de registro que o usuário declarou \" +\n \"na mensagem: planos de gasto futuro, preferências duradouras ou fatos pessoais-financeiros relevantes. \" +\n \"Cada item: {texto (frase curta e autocontida em 3ª pessoa), tipo (PLANO_FUTURO|PREFERENCIA|FATO), \" +\n \"valor (número em reais se citado), mesAlvo (1-12 se citado), anoAlvo (ex.: 2026 se citado)}. \" +\n \"Ex.: 'mês que vem vou viajar e gastar uns 1500' → [{\\\"texto\\\":\\\"Planeja viajar e gastar cerca de R$ 1500\\\",\" +\n \"\\\"tipo\\\":\\\"PLANO_FUTURO\\\",\\\"valor\\\":1500,\\\"mesAlvo\\\":<mês seguinte>}]. \" +\n \"NÃO inclua comandos operacionais (registrar despesa, consultar saldo), pedidos pontuais nem dados sensíveis; \" +\n \"na dúvida, devolva array vazio. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\";\n if (jarvisConversaJanelaService != null && userId != null) {\n String hist = jarvisConversaJanelaService.montarBlocoHistorico(userId);\n if (hist != null && !hist.isBlank()) {\n userPrompt = hist + \"\\n\" + userPrompt;\n }\n }\n userPrompt += \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount; categoryName quando o estabelecimento \" +\n \"implicar categoria (ex.: ubr/uber→Transporte, mrcdo/mercado→Alimentação); nome_normalizado nas entidades.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Despesa com beneficiário PIX no extrato do cartão (ex.: 'pix Maria Silva 3340 no cartão Itaú em 2x'): paymentMethod=CARTAO, \" +\n \"bank ou cardName = cartão citado (Itaú/Nubank/Azul), description = nome do beneficiário; NÃO use paymentMethod=CONTA nem cardName=beneficiário. \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor, dia_vencimento (1-31).\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se pedir conselho sobre compra, empréstimo, consignado ou financiamento (ex.: 'vale a pena comprar moto de 15k parcelada?', \" +\n \"'consignado de 10 mil em 24x de 550 o que acha?', 'posso comprar um tênis de 800 hoje?', 'devo pegar esse empréstimo?'): \" +\n \"action GET_FINANCIAL_ADVICE; preencha tipoOperacao, valorTotal, valorParcela, quantidadeParcelas, descricaoItem. \" +\n \"Normalização numérica: '15k'/'15 mil'→15000; '10.000'→10000; '24x de 550'→quantidadeParcelas=24, valorParcela=550; \" +\n \"'550 por mês durante 2 anos'→valorParcela=550, quantidadeParcelas=24. \" +\n \"Se citar parcelas/consignado/financiamento→operação parcelada; 'à vista'/'hoje'/'agora' sem parcelas→COMPRA_AVISTA. \" +\n \"NÃO calcule juros nem veredito — apenas extraia parâmetros.\\n\" +\n \"- Se REGISTRAR empréstimo/consignado JÁ CONTRATADO (passado: 'fiz', 'contratei', 'peguei', 'caiu na conta'), \" +\n \"NÃO use GET_FINANCIAL_ADVICE — use action RECORD_CONSIGNMENT_LOAN; preencha valorTomado (ou valorTotal), \" +\n \"quantidadeParcelas, valorParcela se citado, nomeConta/accountName/bank se citar conta.\\n\" +\n \"- Se perguntar SE VALE A PENA / opinião sobre empréstimo futuro: GET_FINANCIAL_ADVICE.\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se perguntar valor/total das faturas de todos os cartões (ex.: 'em quanto tá as faturas dos meus cartões?', \" +\n \"'quanto estão as faturas?', 'valor de cada fatura dos cartões'): action LIST_FATURAS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura de um cartão específico, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se pedir saldos das contas, patrimônio em contas ou quanto tem nas contas (ex.: 'quanto eu tenho nas contas?', \" +\n \"'saldos das contas', 'meu patrimônio', 'lista minhas contas', 'saldo do nu', 'saldo da conta Nubank'): \" +\n \"action LIST_ACCOUNTS; preencher accountName ou bank com o apelido citado; nome_normalizado com o nome corrigido.\\n\" +\n \"- Se pedir transferir/mover/passar valor entre contas bancárias (ex.: 'transfere 100 do Itaú pro Nubank', \" +\n \"'passa 50 reais da conta Inter para a poupança', 'manda 200 da corrente pra carteira'): action TRANSFER_BETWEEN_ACCOUNTS; \" +\n \"preencher amount (valor), contaOrigem e contaDestino com os apelidos citados; NÃO use CREATE_EXPENSE para transferências internas.\\n\" +\n \"- Se pedir extrato, últimos gastos/receitas ou listar transações (ex.: 'quais foram meus últimos gastos?', \" +\n \"'lista os pix que recebi em junho', 'o que gastei na categoria mercado esse mês?'): action LIST_TRANSACTIONS; \" +\n \"preencher mes/ano ou reportMonth/reportYear quando citar período; tipo=DESPESA ou RECEITA se filtrar; categoryName/categoria se citar categoria.\\n\" +\n \"- Se pedir listar categorias cadastradas (ex.: 'quais categorias eu tenho?', 'lista minhas categorias'): action LIST_CATEGORIES.\\n\" +\n \"- Se pedir estado das metas/objetivos (ex.: 'como estão minhas metas?', 'quais são os meus objetivos de economia?'): action LIST_METAS.\\n\" +\n \"- Se pedir resumo de orçamento, limites por categoria ou gasto vs limite (ex.: 'quanto gastei do meu orçamento?', \" +\n \"'resumo de gastos do orçamento', 'meus limites de categoria'): action GET_REPORT_SUMMARY; mes/ano se citar mês.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação', \" +\n \"'apague despesa fixa aluguel', 'remova assinatura Netflix'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento | despesa_fixa | assinatura; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se configurar *perfil de renda híbrido* (contracheque, recebimento único ou fluxo diário), ex.: \" +\n \"'minha renda agora é fluxo diário com meta de 5000', 'recebo um pix único de 4000 todo dia 10', \" +\n \"'meu salário bruto é 6000 com descontos de 800 dia 5': action SET_INCOME_PROFILE; \" +\n \"tipoPerfil = CONTRACHEQUE | RECEBIMENTO_UNICO | FLUXO_DIARIO; \" +\n \"CONTRACHEQUE: salarioBruto, descontosHolerite (total) ou descontosFixos em updates, diaRecebimento; \" +\n \"RECEBIMENTO_UNICO: valorLiquidoFixo, diaRecebimentoFixo; FLUXO_DIARIO: metaFaturamentoMensal (opcional). \" +\n \"Prefira SET_INCOME_PROFILE quando a frase citar explicitamente o *tipo* de trabalhador/perfil.\\n\" +\n \"- Se confirmar que provisão fiscal caiu na conta (ex.: 'confirma o 13', 'primeira parcela do 13 caiu', \" +\n \"'restituição do IR entrou na conta', 'efetiva o décimo terceiro'): action CONFIRM_FISCAL_PROVISION; \" +\n \"description = termo citado (13, restituição IR, etc.); accountName/bank se citar conta específica.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Listar assinaturas cadastradas/monitoradas (ex.: 'quais minhas assinaturas?', 'lista assinaturas'): action LIST_SUBSCRIPTIONS.\\n\" +\n \"- Cadastrar assinatura recorrente manualmente (ex.: 'assinei o plano do Spotify por 24,90 por mês', \" +\n \"'cadastra a assinatura da Netflix de 55,90 todo dia 15'): action CREATE_SUBSCRIPTION; description = nome; amount = valor mensal; dueDay = dia de cobrança (1-31).\\n\" +\n \"- Cadastrar despesa fixa/obrigação mensal (ex.: 'cadastra a despesa fixa de aluguel valor 1200 vencimento todo dia 10', \" +\n \"'salve essa despesa fixa de 250 para internet dia 10'): action CREATE_FIXED_EXPENSE; description = nome; amount = valor; dueDay = dia de vencimento.\\n\" +\n \"- Ativar ou desativar assinatura cadastrada (ex.: 'desative a assinatura da Netflix', 'pause a academia', 'reative o Spotify'): \" +\n \"action TOGGLE_SUBSCRIPTION; description ou searchPhrase = nome da assinatura; subscriptionActive=false para desativar/pausar, true para ativar.\\n\" +\n \"- Tutorial interativo opt-in (guia de uso do sistema): action START_TUTORIAL para frases como \" +\n \"'tutorial', 'como usar', 'me ensina a usar o sistema', 'como funciona isso aqui', 'ajuda', 'help', 'nao sei usar'.\\n\" +\n \"- START_TUTORIAL também quando o utilizador perguntar sobre uma aba/tela específica — preencha reportMonth com o capítulo (1-5) e entregue o capítulo direto, sem menu:\\n\" +\n \" 'como uso a aba de contas?' → reportMonth=1 · 'me explica a tela de renda' → reportMonth=4 · \" +\n \"'o que e a aba de metas?' → reportMonth=5 · 'nao entendo a aba de cartoes' → reportMonth=3 · \" +\n \"'como funciona a aba de transacoes?' → reportMonth=2.\\n\" +\n \"- Encerrar tutorial ativo: action STOP_TUTORIAL para 'sair', 'parar', 'desligar', 'cancelar', 'encerrar tutorial', 'chega', 'voltar ao normal'.\\n\" +\n \"- Navegar capítulo do tutorial (1 a 5) quando o utilizador está no guia: action TUTORIAL_STEP; preencher reportMonth com o número do capítulo (1-5) se útil.\\n\" +\n \"REGRAS DE TOLERÂNCIA A LINGUAGEM:\\n\" +\n \"Ao interpretar a mensagem do usuário, aplique normalizações ANTES de classificar a intenção.\\n\" +\n \"ABREVIAÇÕES FINANCEIRAS: nu/nub/nuba/nubank→Nubank; ita/itau/itá→Itaú; bb/bradesco→Bradesco; inter→Banco Inter; \" +\n \"xp/xpi→XP; cef/caixa/cx→Caixa; sntdr/santander→Santander.\\n\" +\n \"ESTABELECIMENTOS: mrcdo/mercd/mkt/feira/sacolão→Mercado/Alimentação; ubr/uber/99→Transporte; \" +\n \"ifood/ifd/rappi→Alimentação/Delivery; farm/farmci/drog→Farmácia; posto/gasolina/combust→Combustível; \" +\n \"academia/acad/gym→Academia; netf/netflix/spotify/prime→Assinaturas/Lazer.\\n\" +\n \"INFORMAIS: tô no vermelho/zerado/liso→saldo crítico; sobrou/sobra→consulta saldo; fechei/como tá o mês→relatório; \" +\n \"queimei/estourei→orçamento excedido; guardei/separei→poupança/meta; parcelei/em x vezes→parcelado.\\n\" +\n \"REGRAS: (1) corrija erros óbvios pelo contexto; (2) abreviação dupla→entidade_ambigua+opcoes_entidade; \" +\n \"(3) não invente entidade→entidade_desconhecida; (4) preencha nome_normalizado com o nome corrigido.\\n\" +\n \"Intenção GREETING: ative quando a mensagem for APENAS uma saudação informal brasileira \" +\n \"(oi, olá, iae, iai, e aí, salve, opa, fala, beleza, tudo bem, bom dia, boa tarde, boa noite), sem pedido financeiro.\\n\" +\n \"REGRA IMPORTANTE: se além da saudação houver um comando financeiro na mesma frase \" +\n \"(ex.: 'oi jarvis, quanto tenho na conta?'), classifique pela INTENÇÃO FINANCEIRA principal \" +\n \"(LIST_ACCOUNTS, CREATE_EXPENSE, etc.), NÃO como GREETING.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n final double tempFinal = temperature;\n String tokenHint = otimizado.system() + otimizado.user();\n return executeAIRequestWithFallback(\n AITaskType.WHATSAPP_COMMAND,\n userId,\n tempFinal,\n cfg,\n p -> canChatJson(cfg, p),\n tokenHint,\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user(), tempFinal);\n },\n \"Nao foi possivel processar IA (roteador WhatsApp). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\",\n AITaskType.SEMANTIC_ANALYSIS\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String tokenHint = otimizado.system() + otimizado.user();\n String linha = executeAIRequestWithFallback(\n AITaskType.REPORT_GENERATION,\n userId,\n cfg,\n p -> canChatJson(cfg, p),\n tokenHint,\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n return analisarImagemNotaFiscal(imageBytes, contentType, userId, 0.1);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId, double temperature) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId, temperature);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n return analisarImagemNotaFiscalConteudo(imageSource, userId, 0.1);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId, double temperature) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n final double tempFinal = temperature;\n String tokenHint = visionSys + visionUsr;\n\n JsonNode out = executeAIRequestWithFallback(\n AITaskType.OCR_RECEIPT,\n userId,\n tempFinal,\n cfg,\n p -> canVisionForTask(AITaskType.OCR_RECEIPT, cfg, p),\n tokenHint,\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionForProvider(p, c, model, visionSys, visionUsr, imageSource, tempFinal);\n },\n \"Falha OCR em todos provedores (roteador cupom): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n /** Visão computacional para boleto bancário ou QR Code Pix (agendamento de pagamento). */\n public JsonNode analisarImagemBoletoPix(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para leitura de boleto/Pix\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você extrai dados de boleto bancário brasileiro ou QR Code Pix para agendamento de pagamento. \"\n + \"Retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise a imagem e extraia: \"\n + \"tipo (BOLETO|PIX|CUPOM_FISCAL|OUTRO), beneficiario (string), valor (número decimal), \"\n + \"dataVencimento (yyyy-MM-dd), linhaDigitavel ou codigoBarrasOuPix (se visível), \"\n + \"confianca (0 a 1), erro (string opcional). \"\n + \"Use tipo CUPOM_FISCAL se for nota fiscal de compra, não boleto. \"\n + \"Se não for possível ler com segurança, preencha erro e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId, systemPrompt, userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n String tokenHint = visionSys + visionUsr;\n\n JsonNode out = executeAIRequestWithFallback(\n AITaskType.OCR_RECEIPT,\n userId,\n cfg,\n p -> canVisionForTask(AITaskType.OCR_RECEIPT, cfg, p),\n tokenHint,\n (p, c) -> {\n String model = visionModelFor(p, c);\n return parseVisionForProvider(p, c, model, visionSys, visionUsr, dataUrl, 0.1);\n },\n \"Falha leitura boleto/Pix em todos provedores: \"\n );\n log.info(\"[VISION-LOG] Boleto/Pix userId={} tipo={} confianca={}\",\n userId, out.path(\"tipo\").asText(\"\"), out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJson(userId, systemPrompt, userPrompt, AITaskType.STRUCTURED_OUTPUT);\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt, double temperature) {\n return gerarJson(userId, systemPrompt, userPrompt, temperature, AITaskType.STRUCTURED_OUTPUT);\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt, AITaskType taskType) {\n return gerarJson(userId, systemPrompt, userPrompt, 0.1, taskType);\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt, double temperature, AITaskType taskType) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false, temperature, taskType);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonDocumento(userId, systemPrompt, userPrompt, AITaskType.OCR_INVOICE);\n }\n\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt, double temperature) {\n return gerarJsonDocumento(userId, systemPrompt, userPrompt, temperature, AITaskType.OCR_INVOICE);\n }\n\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt, AITaskType taskType) {\n return gerarJsonDocumento(userId, systemPrompt, userPrompt, 0.1, taskType);\n }\n\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt, double temperature, AITaskType taskType) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true, temperature, taskType);\n }\n\n private JsonNode gerarJsonInternal(\n Long userId,\n String systemPrompt,\n String userPrompt,\n boolean documento,\n double temperature,\n AITaskType taskType\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n final double tempFinal = temperature;\n String tokenHint = systemFinal + userFinal;\n return executeAIRequestWithFallback(\n taskType,\n userId,\n tempFinal,\n cfg,\n p -> canChatJson(cfg, p),\n tokenHint,\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal, tempFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n return gerarTexto(userId, systemPrompt, userPrompt, fallback, AITaskType.CHAT);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback, AITaskType taskType) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt,\n taskType);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Narra o conselho financeiro — a IA só redige; todos os números já foram calculados em Java.\n */\n public String narrarConselho(Long userId, ResultadoConselho resultado) {\n if (resultado == null) {\n String voc = tratamentoUsuarioService.vocativoPorId(userId, usuarioRepository);\n return tratamentoUsuarioService.prefixoVocativo(voc)\n + \"não consegui montar o cenário agora. Tenta de novo com valor e parcelas, se houver.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n String vereditoTxt = switch (resultado.getVeredito() != null ? resultado.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"RISCO_ALTO\";\n case SEGURO -> \"SEGURO\";\n case ATENCAO -> \"ATENCAO\";\n };\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Veredito: \").append(vereditoTxt).append(\"\\n\");\n dados.append(\"- Item: \").append(resultado.getDescricaoItem() != null ? resultado.getDescricaoItem() : \"item\").append(\"\\n\");\n if (resultado.getCustoTotal() != null) {\n dados.append(\"- Custo total: \").append(brl.format(resultado.getCustoTotal())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensal() != null && resultado.getTaxaJurosAnual() != null) {\n dados.append(\"- Taxa de juros: \").append(resultado.getTaxaJurosMensal()).append(\"% ao mês / \")\n .append(resultado.getTaxaJurosAnual()).append(\"% ao ano\\n\");\n }\n if (resultado.getPercentualRendaComprometida() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPercentualRendaComprometida()).append(\"%\\n\");\n }\n if (resultado.getSaldoAposCompra() != null) {\n dados.append(\"- Saldo após a compra: \").append(brl.format(resultado.getSaldoAposCompra())).append(\"\\n\");\n }\n if (resultado.getReservaMesesApos() != null) {\n dados.append(\"- Reserva após a compra: \").append(resultado.getReservaMesesApos()).append(\" meses\\n\");\n }\n if (resultado.getMesesReservaAtual() != null) {\n dados.append(\"- Reserva atual (Escudo): \").append(resultado.getMesesReservaAtual()).append(\" meses\\n\");\n }\n if (resultado.getComparacaoMercado() != null) {\n dados.append(\"- Comparação com o mercado: \").append(resultado.getComparacaoMercado());\n if (resultado.getTaxaMercadoReferencia() != null) {\n dados.append(\" (referência \").append(resultado.getTaxaMercadoReferencia()).append(\"% a.a.)\");\n }\n dados.append(\"\\n\");\n }\n if (resultado.isAvisoSemValorTomado()) {\n dados.append(\"- Aviso: valor tomado/à vista não informado — juros não calculados\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String trat = tratamentoUsuarioService.tratamentoConversacional(u, null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S., estrategista financeiro pessoal. Os cálculos JÁ FORAM FEITOS.\\n\"\n + \"Sua tarefa é APENAS transformar os dados abaixo em uma resposta clara, direta e humana.\\n\"\n + \"NÃO recalcule nada. NÃO invente números. Use exatamente os valores fornecidos.\\n\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Comece com o selo do veredito: 🔴 *[RISCO ALTO]* | 🟡 *[ATENÇÃO]* | 🟢 *[PODE IR]*\\n\"\n + \"2. Explique o PORQUÊ em linguagem simples, sem jargão técnico.\\n\"\n + \"3. Se houver juros, mostre o impacto concreto no bolso.\\n\"\n + \"4. Para parcelas, diga quantos % da renda ficam comprometidos.\\n\"\n + \"5. Em decisões grandes, feche lembrando que a decisão é de \" + trat + \".\\n\"\n + \"6. Tom: direto, honesto, protetor do bolso. Trate por \\\"\" + trat + \"\\\".\\n\"\n + \"7. Máximo 2 emojis além do selo.\\n\"\n + \"8. Orientação baseada nos dados cadastrados — não é garantia nem consultoria profissional.\\n\";\n String userPrompt = \"DADOS CALCULADOS:\\n\" + dados;\n String fallback = montarFallbackDeterministico(userId, resultado, brl);\n return gerarTexto(userId, system, userPrompt, fallback, AITaskType.FINANCIAL_ADVISOR);\n }\n public String narrarRegistroEmprestimo(Long userId, ResultadoRegistroEmprestimo resultado) {\n if (resultado == null) {\n String voc = tratamentoUsuarioService.vocativoPorId(userId, usuarioRepository);\n return tratamentoUsuarioService.prefixoVocativo(voc)\n + \"não consegui concluir o registro do empréstimo agora.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Valor creditado: \").append(brl.format(resultado.getValorTomado())).append(\"\\n\");\n if (resultado.getContaNome() != null) {\n dados.append(\"- Conta: \").append(resultado.getContaNome()).append(\"\\n\");\n }\n if (resultado.getNovoSaldoConta() != null) {\n dados.append(\"- Novo saldo da conta: \").append(brl.format(resultado.getNovoSaldoConta())).append(\"\\n\");\n }\n dados.append(\"- Parcela: \").append(brl.format(resultado.getValorParcela()));\n if (resultado.isParcelaEstimada()) {\n dados.append(\" (estimada pela taxa média de mercado)\");\n }\n dados.append(\"\\n\");\n dados.append(\"- Nº de parcelas: \").append(resultado.getQuantidadeParcelas());\n if (resultado.getDataPrimeiraParcela() != null && resultado.getDataUltimaParcela() != null) {\n dados.append(\" (\").append(resultado.getDataPrimeiraParcela())\n .append(\" até \").append(resultado.getDataUltimaParcela()).append(\")\\n\");\n } else {\n dados.append(\"\\n\");\n }\n if (resultado.getTotalAPagar() != null) {\n dados.append(\"- Total a pagar: \").append(brl.format(resultado.getTotalAPagar())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensalPct() != null && resultado.getTaxaJurosAnualPct() != null) {\n dados.append(\"- Taxa efetiva: \").append(resultado.getTaxaJurosMensalPct())\n .append(\"% a.m. / \").append(resultado.getTaxaJurosAnualPct()).append(\"% a.a.\\n\");\n }\n if (resultado.getPctRendaComprometidaDepois() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPctRendaComprometidaDepois()).append(\"%\\n\");\n }\n if (resultado.getRendaLivreAntes() != null && resultado.getRendaLivreDepois() != null) {\n dados.append(\"- Renda livre: era \").append(brl.format(resultado.getRendaLivreAntes()))\n .append(\", ficou \").append(brl.format(resultado.getRendaLivreDepois())).append(\"\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String trat = tratamentoUsuarioService.tratamentoConversacional(u, null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S. Os cálculos JÁ FORAM FEITOS — NÃO recalcule.\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Confirme o crédito e o novo saldo primeiro.\\n\"\n + \"2. Mostre o custo real: quanto recebe vs quanto devolve e juros.\\n\"\n + \"3. Diga quanto da renda fica comprometido e por quantos meses.\\n\"\n + \"4. Se parcela estimada, avise para informar valor exato do contrato.\\n\"\n + \"5. Linguagem simples. Trate por \\\"\" + trat + \"\\\". Máximo 2 emojis.\\n\"\n + \"6. Feche lembrando que dá pra acompanhar na aba Transações.\\n\";\n String fallback = montarFallbackRegistroEmprestimo(userId, resultado, brl);\n return gerarTexto(userId, system, \"DADOS CALCULADOS (não recalcule, apenas redija):\\n\" + dados, fallback, AITaskType.FINANCIAL_ADVISOR);\n }\n\n private String montarFallbackRegistroEmprestimo(Long userId, ResultadoRegistroEmprestimo r, NumberFormat brl) {\n String voc = tratamentoUsuarioService.vocativoPorId(userId, usuarioRepository);\n StringBuilder sb = new StringBuilder();\n sb.append(tratamentoUsuarioService.feitoExclamacao(voc)).append(\"Creditei *\").append(brl.format(r.getValorTomado())).append(\"*\");\n if (r.getContaNome() != null) {\n sb.append(\" na *\").append(r.getContaNome()).append(\"*\");\n }\n if (r.getNovoSaldoConta() != null) {\n sb.append(\" — saldo agora *\").append(brl.format(r.getNovoSaldoConta())).append(\"*\");\n }\n sb.append(\".\\n\");\n sb.append(\"São *\").append(r.getQuantidadeParcelas()).append(\"x de \")\n .append(brl.format(r.getValorParcela()));\n if (r.isParcelaEstimada()) {\n sb.append(\" _(estimada — me passe o valor exato do contrato pra ajustar)_\");\n }\n sb.append(\".\\n\");\n if (r.getJurosTotais() != null && r.getTotalAPagar() != null) {\n sb.append(\"Você recebe \").append(brl.format(r.getValorTomado()))\n .append(\", mas devolve \").append(brl.format(r.getTotalAPagar()))\n .append(\" — \").append(brl.format(r.getJurosTotais())).append(\" de juros.\\n\");\n }\n if (r.getPctRendaComprometidaDepois() != null) {\n sb.append(\"Comprometimento da renda: *\").append(r.getPctRendaComprometidaDepois()).append(\"%*.\\n\");\n }\n sb.append(\"Acompanhe as parcelas em *Transações*.\");\n return sb.toString();\n }\n\n private String montarFallbackDeterministico(Long userId, ResultadoConselho r, NumberFormat brl) {\n String voc = tratamentoUsuarioService.vocativoPorId(userId, usuarioRepository);\n String selo = switch (r.getVeredito() != null ? r.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"🔴 *[RISCO ALTO]*\";\n case SEGURO -> \"🟢 *[PODE IR]*\";\n case ATENCAO -> \"🟡 *[ATENÇÃO]*\";\n };\n StringBuilder sb = new StringBuilder(selo).append(\" \");\n sb.append(\"Sobre \").append(r.getDescricaoItem() != null ? r.getDescricaoItem() : \"essa operação\").append(\": \");\n if (r.getCustoTotal() != null) {\n sb.append(\"custo total \").append(brl.format(r.getCustoTotal())).append(\". \");\n }\n if (r.getJurosTotais() != null && r.getJurosTotais().compareTo(BigDecimal.ZERO) > 0) {\n sb.append(\"Juros: \").append(brl.format(r.getJurosTotais())).append(\". \");\n }\n if (r.getPercentualRendaComprometida() != null) {\n sb.append(\"Compromete \").append(r.getPercentualRendaComprometida()).append(\"% da sua renda. \");\n }\n if (r.getSaldoAposCompra() != null) {\n sb.append(\"Saldo após compra: \").append(brl.format(r.getSaldoAposCompra())).append(\". \");\n }\n sb.append(tratamentoUsuarioService.palavraFinal(voc));\n return sb.toString();\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Roteamento inteligente por tipo de tarefa — fallback apenas dentro da categoria.\n */\n private <T> T executeAIRequestWithFallback(\n AITaskType taskType,\n Long userId,\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n String tokenEstimateInput,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(taskType, userId, null, cfg, canUseProvider, tokenEstimateInput, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AITaskType taskType,\n Long userId,\n Double temperature,\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n String tokenEstimateInput,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n try {\n return aiRouterService.route(\n AiRouterRequestContext.builder().userId(userId).temperature(temperature).build(),\n taskType,\n cfg,\n canUseProvider,\n tokenEstimateInput != null ? tokenEstimateInput : \"\",\n attempt::execute,\n failureMessagePrefix\n );\n } catch (RuntimeException e) {\n if (e.getMessage() != null && e.getMessage().contains(\"nenhum provedor elegível\")) {\n appendMissingPlatformProvidersHint(cfg, e);\n }\n throw e;\n }\n }\n\n private void appendMissingPlatformProvidersHint(AiProvidersConfig cfg, RuntimeException e) {\n // mantém mensagens de diagnóstico legadas quando chaves de plataforma faltam\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n log.debug(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n List<String> userOrder = cfg != null && cfg.getProviderOrder() != null\n ? cfg.getProviderOrder()\n : List.of();\n List<AiProviderType> out = new ArrayList<>();\n for (String name : userOrder) {\n AiProviderType t = AiProviderType.fromString(name);\n if (t != null && !out.contains(t)) {\n out.add(t);\n }\n }\n for (AiProviderType t : AiProviderOrder.canonicalTypes()) {\n if (!out.contains(t)) {\n out.add(t);\n }\n }\n return out;\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n /** OCR de cupom/fatura/contracheque — Gemini e Claude também suportam visão. */\n private boolean canVisionForTask(AITaskType taskType, AiProvidersConfig cfg, AiProviderType p) {\n if (taskType == AITaskType.OCR_RECEIPT || taskType == AITaskType.OCR_INVOICE || taskType == AITaskType.OCR_PAYSLIP) {\n return switch (p) {\n case GEMINI, CLAUDE, GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n default -> false;\n };\n }\n return canVision(cfg, p);\n }\n\n private JsonNode parseVisionForProvider(\n AiProviderType provider,\n AiProvidersConfig cfg,\n String model,\n String systemPrompt,\n String userPrompt,\n String imageSource,\n double temperature\n ) throws Exception {\n return switch (provider) {\n case GEMINI -> parseGeminiVisionJson(model, systemPrompt, userPrompt, imageSource, temperature);\n case CLAUDE -> parseClaudeVisionJson(model, systemPrompt, userPrompt, imageSource);\n default -> parseVisionOpenAiCompatible(\n provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt, imageSource, temperature);\n };\n }\n\n private record ImageInline(String mimeType, String base64) {}\n\n private ImageInline parseImageSource(String imageSource) {\n if (imageSource == null || imageSource.isBlank()) {\n throw new RuntimeException(\"Imagem não informada\");\n }\n if (imageSource.startsWith(\"data:\")) {\n int comma = imageSource.indexOf(',');\n if (comma < 0) {\n throw new RuntimeException(\"Data URL inválida\");\n }\n String meta = imageSource.substring(5, comma);\n String mime = meta.contains(\";\") ? meta.substring(0, meta.indexOf(';')) : meta;\n return new ImageInline(mime.isBlank() ? \"image/jpeg\" : mime, imageSource.substring(comma + 1));\n }\n throw new RuntimeException(\"Provedor requer imagem em data URL (base64)\");\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return parseChatJsonForProvider(provider, cfg, model, systemPrompt, userPrompt, 0.1);\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt, double temperature) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt, temperature);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt, temperature);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = parseRestTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt, double temperature) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", temperature,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = parseRestTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseGeminiVisionJson(\n String model, String systemPrompt, String userPrompt, String imageSource, double temperature\n ) throws Exception {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n ImageInline img = parseImageSource(imageSource);\n\n List<Map<String, Object>> parts = new ArrayList<>();\n parts.add(Map.of(\"text\", userPrompt));\n parts.add(Map.of(\"inline_data\", Map.of(\"mime_type\", img.mimeType(), \"data\", img.base64())));\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"systemInstruction\", Map.of(\"parts\", List.of(Map.of(\"text\", systemPrompt))));\n payload.put(\"contents\", List.of(Map.of(\"role\", \"user\", \"parts\", parts)));\n payload.put(\"generationConfig\", Map.of(\n \"temperature\", temperature,\n \"responseMimeType\", \"application/json\"\n ));\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = parseRestTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n log.info(\"IA processada via GEMINI (visão/json)\");\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseClaudeVisionJson(String model, String systemPrompt, String userPrompt, String imageSource)\n throws Exception {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n ImageInline img = parseImageSource(imageSource);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"image\", \"source\", Map.of(\n \"type\", \"base64\",\n \"media_type\", img.mimeType(),\n \"data\", img.base64()\n )),\n Map.of(\"type\", \"text\", \"text\", userPrompt)\n )\n )));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = parseRestTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (visão/json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n return parseCommandOpenAiCompatible(providerName, key, providerBaseUrl, model, systemPrompt, userPrompt, 0.1);\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, double temperature) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama, temperature);\n ResponseEntity<String> response = callOpenAiCompatibleForParse(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n return parseVisionOpenAiCompatible(providerName, key, providerBaseUrl, model, systemPrompt, userPrompt, imageSource, 0.1);\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource, double temperature) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama, temperature);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n return buildChatJsonPayload(model, systemPrompt, userPrompt, useOpenAiJsonMode, 0.1);\n }\n\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode, double temperature\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", temperature);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n return buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, useOpenAiJsonMode, 0.1);\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode, double temperature\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", temperature);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n return callOpenAiCompatibleForParse(providerBaseUrl, key, payload, false);\n }\n\n private ResponseEntity<String> callOpenAiCompatibleForParse(String providerBaseUrl, String key, Map<String, Object> payload) {\n return callOpenAiCompatibleForParse(providerBaseUrl, key, payload, true);\n }\n\n private ResponseEntity<String> callOpenAiCompatibleForParse(\n String providerBaseUrl, String key, Map<String, Object> payload, boolean parse\n ) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n RestTemplate rt = parse ? parseRestTemplate : restTemplate;\n return rt.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "53fe97ecde44cbe20206eb2526db0b7939d99e9b": {"t": 1785752794.1417239, "n": 88839, "h": "4aaa4e7f3418681e", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.ResultadoConselho;\nimport com.consumoesperto.model.ResultadoRegistroEmprestimo;\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.model.Veredito;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.jarvis.TratamentoUsuarioService;\nimport com.consumoesperto.service.ai.AiRouterRequestContext;\nimport com.consumoesperto.service.ai.AITaskType;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.service.ai.AiRouterService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.text.NumberFormat;\nimport java.util.ArrayList;\nimport java.util.Locale;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n private final AiRouterService aiRouterService;\n private final TratamentoUsuarioService tratamentoUsuarioService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private com.consumoesperto.service.jarvis.JarvisContextoFinanceiroCacheService jarvisContextoFinanceiroCacheService;\n\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private com.consumoesperto.service.jarvis.JarvisConversaJanelaService jarvisConversaJanelaService;\n\n @Value(\"${consumoesperto.jarvis.performance.parse-read-timeout-ms:25000}\")\n private int parseReadTimeoutMs;\n\n private RestTemplate restTemplate;\n private RestTemplate parseRestTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n\n SimpleClientHttpRequestFactory parseFactory = new SimpleClientHttpRequestFactory();\n parseFactory.setConnectTimeout(Math.max(3_000, Math.min(aiHttpConnectTimeoutMs, 10_000)));\n parseFactory.setReadTimeout(Math.max(5_000, parseReadTimeoutMs));\n parseRestTemplate = new RestTemplate(parseFactory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String tokenHint = filename != null ? filename : \"audio\";\n return executeAIRequestWithFallback(\n AITaskType.VOICE_TRANSCRIPTION,\n userId,\n cfg,\n p -> canTranscribe(cfg, p),\n tokenHint,\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null) {\n return \"\";\n }\n try {\n if (jarvisContextoFinanceiroCacheService != null) {\n return jarvisContextoFinanceiroCacheService.montarBlocoContexto(userId);\n }\n return \"\";\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n return parseCommand(inputText, userId, 0.1);\n }\n\n /** Retry estruturado: use temperatura 0 para resposta determinística. */\n public JsonNode parseCommand(String inputText, Long userId, double temperature) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|CREATE_FIXED_EXPENSE|CREATE_SUBSCRIPTION|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_FINANCIAL_ADVICE|RECORD_CONSIGNMENT_LOAN|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|LIST_FATURAS|LIST_ACCOUNTS|TRANSFER_BETWEEN_ACCOUNTS|LIST_TRANSACTIONS|LIST_CATEGORIES|LIST_METAS|GET_REPORT_SUMMARY|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|SET_INCOME_PROFILE|CONFIRM_FISCAL_PROVISION|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|LIST_SUBSCRIPTIONS|TOGGLE_SUBSCRIPTION|START_TUTORIAL|STOP_TUTORIAL|TUTORIAL_STEP|GREETING|UNKNOWN), \" +\n \"valorTomado (valor recebido do empréstimo consignado), nomeConta (apelido da conta que recebeu), \" +\n \"contaOrigem (apelido/nome da conta de origem na transferência), contaDestino (apelido/nome da conta de destino na transferência), \" +\n \"mes (1-12, opcional — consultas de extrato/orçamento), ano (ex.: 2026, opcional), tipo (DESPESA|RECEITA, opcional — filtro de extrato), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento|despesa_fixa|assinatura), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"subscriptionActive (true para ativar/reativar assinatura cadastrada; false para desativar/pausar), \" +\n \"tipoPerfil (CONTRACHEQUE|RECEBIMENTO_UNICO|FLUXO_DIARIO — perfil híbrido de renda), \" +\n \"salarioBruto, descontosHolerite (total ou use descontosFixos em updates), diaRecebimento (1-31, CONTRACHEQUE), \" +\n \"valorLiquidoFixo, diaRecebimentoFixo (RECEBIMENTO_UNICO), metaFaturamentoMensal (FLUXO_DIARIO), \" +\n \"tipoOperacao (COMPRA_AVISTA|COMPRA_PARCELADA|EMPRESTIMO|CONSIGNADO|FINANCIAMENTO — conselho de compra/empréstimo), \" +\n \"valorTotal (valor do bem ou valor tomado no empréstimo), valorParcela (valor de cada parcela), \" +\n \"quantidadeParcelas (número de parcelas), descricaoItem (ex.: moto, tênis, consignado), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"nome_normalizado (nome corrigido da entidade identificada), entidade_ambigua (true se abreviação dupla), \" +\n \"entidade_desconhecida (true se não reconhecer), opcoes_entidade (array de nomes quando ambíguo), \" +\n \"confianca (0-1), errorMessage, \" +\n \"memoriasSugeridas (array OPCIONAL de memórias de longo prazo dignas de registro que o usuário declarou \" +\n \"na mensagem: planos de gasto futuro, preferências duradouras ou fatos pessoais-financeiros relevantes. \" +\n \"Cada item: {texto (frase curta e autocontida em 3ª pessoa), tipo (PLANO_FUTURO|PREFERENCIA|FATO), \" +\n \"valor (número em reais se citado), mesAlvo (1-12 se citado), anoAlvo (ex.: 2026 se citado)}. \" +\n \"Ex.: 'mês que vem vou viajar e gastar uns 1500' → [{\\\"texto\\\":\\\"Planeja viajar e gastar cerca de R$ 1500\\\",\" +\n \"\\\"tipo\\\":\\\"PLANO_FUTURO\\\",\\\"valor\\\":1500,\\\"mesAlvo\\\":<mês seguinte>}]. \" +\n \"NÃO inclua comandos operacionais (registrar despesa, consultar saldo), pedidos pontuais nem dados sensíveis; \" +\n \"na dúvida, devolva array vazio. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\";\n if (jarvisConversaJanelaService != null && userId != null) {\n String hist = jarvisConversaJanelaService.montarBlocoHistorico(userId);\n if (hist != null && !hist.isBlank()) {\n userPrompt = hist + \"\\n\" + userPrompt;\n }\n }\n userPrompt += \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount; categoryName quando o estabelecimento \" +\n \"implicar categoria (ex.: ubr/uber→Transporte, mrcdo/mercado→Alimentação); nome_normalizado nas entidades.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Despesa com beneficiário PIX no extrato do cartão (ex.: 'pix Maria Silva 3340 no cartão Itaú em 2x'): paymentMethod=CARTAO, \" +\n \"bank ou cardName = cartão citado (Itaú/Nubank/Azul), description = nome do beneficiário; NÃO use paymentMethod=CONTA nem cardName=beneficiário. \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor, dia_vencimento (1-31).\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se pedir conselho sobre compra, empréstimo, consignado ou financiamento (ex.: 'vale a pena comprar moto de 15k parcelada?', \" +\n \"'consignado de 10 mil em 24x de 550 o que acha?', 'posso comprar um tênis de 800 hoje?', 'devo pegar esse empréstimo?'): \" +\n \"action GET_FINANCIAL_ADVICE; preencha tipoOperacao, valorTotal, valorParcela, quantidadeParcelas, descricaoItem. \" +\n \"Normalização numérica: '15k'/'15 mil'→15000; '10.000'→10000; '24x de 550'→quantidadeParcelas=24, valorParcela=550; \" +\n \"'550 por mês durante 2 anos'→valorParcela=550, quantidadeParcelas=24. \" +\n \"Se citar parcelas/consignado/financiamento→operação parcelada; 'à vista'/'hoje'/'agora' sem parcelas→COMPRA_AVISTA. \" +\n \"NÃO calcule juros nem veredito — apenas extraia parâmetros.\\n\" +\n \"- Se REGISTRAR empréstimo/consignado JÁ CONTRATADO (passado: 'fiz', 'contratei', 'peguei', 'caiu na conta'), \" +\n \"NÃO use GET_FINANCIAL_ADVICE — use action RECORD_CONSIGNMENT_LOAN; preencha valorTomado (ou valorTotal), \" +\n \"quantidadeParcelas, valorParcela se citado, nomeConta/accountName/bank se citar conta.\\n\" +\n \"- Se perguntar SE VALE A PENA / opinião sobre empréstimo futuro: GET_FINANCIAL_ADVICE.\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se perguntar valor/total das faturas de todos os cartões (ex.: 'em quanto tá as faturas dos meus cartões?', \" +\n \"'quanto estão as faturas?', 'valor de cada fatura dos cartões'): action LIST_FATURAS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura de um cartão específico, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se pedir saldos das contas, patrimônio em contas ou quanto tem nas contas (ex.: 'quanto eu tenho nas contas?', \" +\n \"'saldos das contas', 'meu patrimônio', 'lista minhas contas', 'saldo do nu', 'saldo da conta Nubank'): \" +\n \"action LIST_ACCOUNTS; preencher accountName ou bank com o apelido citado; nome_normalizado com o nome corrigido.\\n\" +\n \"- Se pedir transferir/mover/passar valor entre contas bancárias (ex.: 'transfere 100 do Itaú pro Nubank', \" +\n \"'passa 50 reais da conta Inter para a poupança', 'manda 200 da corrente pra carteira'): action TRANSFER_BETWEEN_ACCOUNTS; \" +\n \"preencher amount (valor), contaOrigem e contaDestino com os apelidos citados; NÃO use CREATE_EXPENSE para transferências internas.\\n\" +\n \"- Se pedir extrato, últimos gastos/receitas ou listar transações (ex.: 'quais foram meus últimos gastos?', \" +\n \"'lista os pix que recebi em junho', 'o que gastei na categoria mercado esse mês?'): action LIST_TRANSACTIONS; \" +\n \"preencher mes/ano ou reportMonth/reportYear quando citar período; tipo=DESPESA ou RECEITA se filtrar; categoryName/categoria se citar categoria.\\n\" +\n \"- Se pedir listar categorias cadastradas (ex.: 'quais categorias eu tenho?', 'lista minhas categorias'): action LIST_CATEGORIES.\\n\" +\n \"- Se pedir estado das metas/objetivos (ex.: 'como estão minhas metas?', 'quais são os meus objetivos de economia?'): action LIST_METAS.\\n\" +\n \"- Se pedir resumo de orçamento, limites por categoria ou gasto vs limite (ex.: 'quanto gastei do meu orçamento?', \" +\n \"'resumo de gastos do orçamento', 'meus limites de categoria'): action GET_REPORT_SUMMARY; mes/ano se citar mês.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação', \" +\n \"'apague despesa fixa aluguel', 'remova assinatura Netflix'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento | despesa_fixa | assinatura; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se configurar *perfil de renda híbrido* (contracheque, recebimento único ou fluxo diário), ex.: \" +\n \"'minha renda agora é fluxo diário com meta de 5000', 'recebo um pix único de 4000 todo dia 10', \" +\n \"'meu salário bruto é 6000 com descontos de 800 dia 5': action SET_INCOME_PROFILE; \" +\n \"tipoPerfil = CONTRACHEQUE | RECEBIMENTO_UNICO | FLUXO_DIARIO; \" +\n \"CONTRACHEQUE: salarioBruto, descontosHolerite (total) ou descontosFixos em updates, diaRecebimento; \" +\n \"RECEBIMENTO_UNICO: valorLiquidoFixo, diaRecebimentoFixo; FLUXO_DIARIO: metaFaturamentoMensal (opcional). \" +\n \"Prefira SET_INCOME_PROFILE quando a frase citar explicitamente o *tipo* de trabalhador/perfil.\\n\" +\n \"- Se confirmar que provisão fiscal caiu na conta (ex.: 'confirma o 13', 'primeira parcela do 13 caiu', \" +\n \"'restituição do IR entrou na conta', 'efetiva o décimo terceiro'): action CONFIRM_FISCAL_PROVISION; \" +\n \"description = termo citado (13, restituição IR, etc.); accountName/bank se citar conta específica.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Listar assinaturas cadastradas/monitoradas (ex.: 'quais minhas assinaturas?', 'lista assinaturas'): action LIST_SUBSCRIPTIONS.\\n\" +\n \"- Cadastrar assinatura recorrente manualmente (ex.: 'assinei o plano do Spotify por 24,90 por mês', \" +\n \"'cadastra a assinatura da Netflix de 55,90 todo dia 15'): action CREATE_SUBSCRIPTION; description = nome; amount = valor mensal; dueDay = dia de cobrança (1-31).\\n\" +\n \"- Cadastrar despesa fixa/obrigação mensal (ex.: 'cadastra a despesa fixa de aluguel valor 1200 vencimento todo dia 10', \" +\n \"'salve essa despesa fixa de 250 para internet dia 10'): action CREATE_FIXED_EXPENSE; description = nome; amount = valor; dueDay = dia de vencimento.\\n\" +\n \"- Ativar ou desativar assinatura cadastrada (ex.: 'desative a assinatura da Netflix', 'pause a academia', 'reative o Spotify'): \" +\n \"action TOGGLE_SUBSCRIPTION; description ou searchPhrase = nome da assinatura; subscriptionActive=false para desativar/pausar, true para ativar.\\n\" +\n \"- Tutorial interativo opt-in (guia de uso do sistema): action START_TUTORIAL para frases como \" +\n \"'tutorial', 'como usar', 'me ensina a usar o sistema', 'como funciona isso aqui', 'ajuda', 'help', 'nao sei usar'.\\n\" +\n \"- START_TUTORIAL também quando o utilizador perguntar sobre uma aba/tela específica — preencha reportMonth com o capítulo (1-5) e entregue o capítulo direto, sem menu:\\n\" +\n \" 'como uso a aba de contas?' → reportMonth=1 · 'me explica a tela de renda' → reportMonth=4 · \" +\n \"'o que e a aba de metas?' → reportMonth=5 · 'nao entendo a aba de cartoes' → reportMonth=3 · \" +\n \"'como funciona a aba de transacoes?' → reportMonth=2.\\n\" +\n \"- Encerrar tutorial ativo: action STOP_TUTORIAL para 'sair', 'parar', 'desligar', 'cancelar', 'encerrar tutorial', 'chega', 'voltar ao normal'.\\n\" +\n \"- Navegar capítulo do tutorial (1 a 5) quando o utilizador está no guia: action TUTORIAL_STEP; preencher reportMonth com o número do capítulo (1-5) se útil.\\n\" +\n \"REGRAS DE TOLERÂNCIA A LINGUAGEM:\\n\" +\n \"Ao interpretar a mensagem do usuário, aplique normalizações ANTES de classificar a intenção.\\n\" +\n \"ABREVIAÇÕES FINANCEIRAS: nu/nub/nuba/nubank→Nubank; ita/itau/itá→Itaú; bb/bradesco→Bradesco; inter→Banco Inter; \" +\n \"xp/xpi→XP; cef/caixa/cx→Caixa; sntdr/santander→Santander.\\n\" +\n \"ESTABELECIMENTOS: mrcdo/mercd/mkt/feira/sacolão→Mercado/Alimentação; ubr/uber/99→Transporte; \" +\n \"ifood/ifd/rappi→Alimentação/Delivery; farm/farmci/drog→Farmácia; posto/gasolina/combust→Combustível; \" +\n \"academia/acad/gym→Academia; netf/netflix/spotify/prime→Assinaturas/Lazer.\\n\" +\n \"INFORMAIS: tô no vermelho/zerado/liso→saldo crítico; sobrou/sobra→consulta saldo; fechei/como tá o mês→relatório; \" +\n \"queimei/estourei→orçamento excedido; guardei/separei→poupança/meta; parcelei/em x vezes→parcelado.\\n\" +\n \"REGRAS: (1) corrija erros óbvios pelo contexto; (2) abreviação dupla→entidade_ambigua+opcoes_entidade; \" +\n \"(3) não invente entidade→entidade_desconhecida; (4) preencha nome_normalizado com o nome corrigido.\\n\" +\n \"Intenção GREETING: ative quando a mensagem for APENAS uma saudação informal brasileira \" +\n \"(oi, olá, iae, iai, e aí, salve, opa, fala, beleza, tudo bem, bom dia, boa tarde, boa noite), sem pedido financeiro.\\n\" +\n \"REGRA IMPORTANTE: se além da saudação houver um comando financeiro na mesma frase \" +\n \"(ex.: 'oi jarvis, quanto tenho na conta?'), classifique pela INTENÇÃO FINANCEIRA principal \" +\n \"(LIST_ACCOUNTS, CREATE_EXPENSE, etc.), NÃO como GREETING.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n final double tempFinal = temperature;\n String tokenHint = otimizado.system() + otimizado.user();\n return executeAIRequestWithFallback(\n AITaskType.WHATSAPP_COMMAND,\n userId,\n tempFinal,\n cfg,\n p -> canChatJson(cfg, p),\n tokenHint,\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user(), tempFinal);\n },\n \"Nao foi possivel processar IA (roteador WhatsApp). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\",\n AITaskType.SEMANTIC_ANALYSIS\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String tokenHint = otimizado.system() + otimizado.user();\n String linha = executeAIRequestWithFallback(\n AITaskType.REPORT_GENERATION,\n userId,\n cfg,\n p -> canChatJson(cfg, p),\n tokenHint,\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n return analisarImagemNotaFiscal(imageBytes, contentType, userId, 0.1);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId, double temperature) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId, temperature);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n return analisarImagemNotaFiscalConteudo(imageSource, userId, 0.1);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId, double temperature) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n final double tempFinal = temperature;\n String tokenHint = visionSys + visionUsr;\n\n JsonNode out = executeAIRequestWithFallback(\n AITaskType.OCR_RECEIPT,\n userId,\n tempFinal,\n cfg,\n p -> canVisionForTask(AITaskType.OCR_RECEIPT, cfg, p),\n tokenHint,\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionForProvider(p, c, model, visionSys, visionUsr, imageSource, tempFinal);\n },\n \"Falha OCR em todos provedores (roteador cupom): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n /** Visão computacional para boleto bancário ou QR Code Pix (agendamento de pagamento). */\n public JsonNode analisarImagemBoletoPix(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para leitura de boleto/Pix\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você extrai dados de boleto bancário brasileiro ou QR Code Pix para agendamento de pagamento. \"\n + \"Retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise a imagem e extraia: \"\n + \"tipo (BOLETO|PIX|CUPOM_FISCAL|OUTRO), beneficiario (string), valor (número decimal), \"\n + \"dataVencimento (yyyy-MM-dd), linhaDigitavel ou codigoBarrasOuPix (se visível), \"\n + \"confianca (0 a 1), erro (string opcional). \"\n + \"Use tipo CUPOM_FISCAL se for nota fiscal de compra, não boleto. \"\n + \"Se não for possível ler com segurança, preencha erro e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId, systemPrompt, userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n String tokenHint = visionSys + visionUsr;\n\n JsonNode out = executeAIRequestWithFallback(\n AITaskType.OCR_RECEIPT,\n userId,\n cfg,\n p -> canVisionForTask(AITaskType.OCR_RECEIPT, cfg, p),\n tokenHint,\n (p, c) -> {\n String model = visionModelFor(p, c);\n return parseVisionForProvider(p, c, model, visionSys, visionUsr, dataUrl, 0.1);\n },\n \"Falha leitura boleto/Pix em todos provedores: \"\n );\n log.info(\"[VISION-LOG] Boleto/Pix userId={} tipo={} confianca={}\",\n userId, out.path(\"tipo\").asText(\"\"), out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJson(userId, systemPrompt, userPrompt, AITaskType.STRUCTURED_OUTPUT);\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt, double temperature) {\n return gerarJson(userId, systemPrompt, userPrompt, temperature, AITaskType.STRUCTURED_OUTPUT);\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt, AITaskType taskType) {\n return gerarJson(userId, systemPrompt, userPrompt, 0.1, taskType);\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt, double temperature, AITaskType taskType) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false, temperature, taskType);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonDocumento(userId, systemPrompt, userPrompt, AITaskType.OCR_INVOICE);\n }\n\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt, double temperature) {\n return gerarJsonDocumento(userId, systemPrompt, userPrompt, temperature, AITaskType.OCR_INVOICE);\n }\n\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt, AITaskType taskType) {\n return gerarJsonDocumento(userId, systemPrompt, userPrompt, 0.1, taskType);\n }\n\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt, double temperature, AITaskType taskType) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true, temperature, taskType);\n }\n\n private JsonNode gerarJsonInternal(\n Long userId,\n String systemPrompt,\n String userPrompt,\n boolean documento,\n double temperature,\n AITaskType taskType\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n final double tempFinal = temperature;\n String tokenHint = systemFinal + userFinal;\n return executeAIRequestWithFallback(\n taskType,\n userId,\n tempFinal,\n cfg,\n p -> canChatJson(cfg, p),\n tokenHint,\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal, tempFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n return gerarTexto(userId, systemPrompt, userPrompt, fallback, AITaskType.CHAT);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback, AITaskType taskType) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt,\n taskType);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Narra o conselho financeiro — a IA só redige; todos os números já foram calculados em Java.\n */\n public String narrarConselho(Long userId, ResultadoConselho resultado) {\n if (resultado == null) {\n String voc = tratamentoUsuarioService.vocativoPorId(userId, usuarioRepository);\n return tratamentoUsuarioService.prefixoVocativo(voc)\n + \"não consegui montar o cenário agora. Tenta de novo com valor e parcelas, se houver.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n String vereditoTxt = switch (resultado.getVeredito() != null ? resultado.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"RISCO_ALTO\";\n case SEGURO -> \"SEGURO\";\n case ATENCAO -> \"ATENCAO\";\n };\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Veredito: \").append(vereditoTxt).append(\"\\n\");\n dados.append(\"- Item: \").append(resultado.getDescricaoItem() != null ? resultado.getDescricaoItem() : \"item\").append(\"\\n\");\n if (resultado.getCustoTotal() != null) {\n dados.append(\"- Custo total: \").append(brl.format(resultado.getCustoTotal())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensal() != null && resultado.getTaxaJurosAnual() != null) {\n dados.append(\"- Taxa de juros: \").append(resultado.getTaxaJurosMensal()).append(\"% ao mês / \")\n .append(resultado.getTaxaJurosAnual()).append(\"% ao ano\\n\");\n }\n if (resultado.getPercentualRendaComprometida() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPercentualRendaComprometida()).append(\"%\\n\");\n }\n if (resultado.getSaldoAposCompra() != null) {\n dados.append(\"- Saldo após a compra: \").append(brl.format(resultado.getSaldoAposCompra())).append(\"\\n\");\n }\n if (resultado.getReservaMesesApos() != null) {\n dados.append(\"- Reserva após a compra: \").append(resultado.getReservaMesesApos()).append(\" meses\\n\");\n }\n if (resultado.getMesesReservaAtual() != null) {\n dados.append(\"- Reserva atual (Escudo): \").append(resultado.getMesesReservaAtual()).append(\" meses\\n\");\n }\n if (resultado.getComparacaoMercado() != null) {\n dados.append(\"- Comparação com o mercado: \").append(resultado.getComparacaoMercado());\n if (resultado.getTaxaMercadoReferencia() != null) {\n dados.append(\" (referência \").append(resultado.getTaxaMercadoReferencia()).append(\"% a.a.)\");\n }\n dados.append(\"\\n\");\n }\n if (resultado.isAvisoSemValorTomado()) {\n dados.append(\"- Aviso: valor tomado/à vista não informado — juros não calculados\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String trat = tratamentoUsuarioService.tratamentoConversacional(u, null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S., estrategista financeiro pessoal. Os cálculos JÁ FORAM FEITOS.\\n\"\n + \"Sua tarefa é APENAS transformar os dados abaixo em uma resposta clara, direta e humana.\\n\"\n + \"NÃO recalcule nada. NÃO invente números. Use exatamente os valores fornecidos.\\n\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Comece com o selo do veredito: 🔴 *[RISCO ALTO]* | 🟡 *[ATENÇÃO]* | 🟢 *[PODE IR]*\\n\"\n + \"2. Explique o PORQUÊ em linguagem simples, sem jargão técnico.\\n\"\n + \"3. Se houver juros, mostre o impacto concreto no bolso.\\n\"\n + \"4. Para parcelas, diga quantos % da renda ficam comprometidos.\\n\"\n + \"5. Em decisões grandes, feche lembrando que a decisão é de \" + trat + \".\\n\"\n + \"6. Tom: direto, honesto, protetor do bolso. Trate por \\\"\" + trat + \"\\\".\\n\"\n + \"7. Máximo 2 emojis além do selo.\\n\"\n + \"8. Orientação baseada nos dados cadastrados — não é garantia nem consultoria profissional.\\n\";\n String userPrompt = \"DADOS CALCULADOS:\\n\" + dados;\n String fallback = montarFallbackDeterministico(userId, resultado, brl);\n return gerarTexto(userId, system, userPrompt, fallback, AITaskType.FINANCIAL_ADVISOR);\n }\n public String narrarRegistroEmprestimo(Long userId, ResultadoRegistroEmprestimo resultado) {\n if (resultado == null) {\n String voc = tratamentoUsuarioService.vocativoPorId(userId, usuarioRepository);\n return tratamentoUsuarioService.prefixoVocativo(voc)\n + \"não consegui concluir o registro do empréstimo agora.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Valor creditado: \").append(brl.format(resultado.getValorTomado())).append(\"\\n\");\n if (resultado.getContaNome() != null) {\n dados.append(\"- Conta: \").append(resultado.getContaNome()).append(\"\\n\");\n }\n if (resultado.getNovoSaldoConta() != null) {\n dados.append(\"- Novo saldo da conta: \").append(brl.format(resultado.getNovoSaldoConta())).append(\"\\n\");\n }\n dados.append(\"- Parcela: \").append(brl.format(resultado.getValorParcela()));\n if (resultado.isParcelaEstimada()) {\n dados.append(\" (estimada pela taxa média de mercado)\");\n }\n dados.append(\"\\n\");\n dados.append(\"- Nº de parcelas: \").append(resultado.getQuantidadeParcelas());\n if (resultado.getDataPrimeiraParcela() != null && resultado.getDataUltimaParcela() != null) {\n dados.append(\" (\").append(resultado.getDataPrimeiraParcela())\n .append(\" até \").append(resultado.getDataUltimaParcela()).append(\")\\n\");\n } else {\n dados.append(\"\\n\");\n }\n if (resultado.getTotalAPagar() != null) {\n dados.append(\"- Total a pagar: \").append(brl.format(resultado.getTotalAPagar())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensalPct() != null && resultado.getTaxaJurosAnualPct() != null) {\n dados.append(\"- Taxa efetiva: \").append(resultado.getTaxaJurosMensalPct())\n .append(\"% a.m. / \").append(resultado.getTaxaJurosAnualPct()).append(\"% a.a.\\n\");\n }\n if (resultado.getPctRendaComprometidaDepois() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPctRendaComprometidaDepois()).append(\"%\\n\");\n }\n if (resultado.getRendaLivreAntes() != null && resultado.getRendaLivreDepois() != null) {\n dados.append(\"- Renda livre: era \").append(brl.format(resultado.getRendaLivreAntes()))\n .append(\", ficou \").append(brl.format(resultado.getRendaLivreDepois())).append(\"\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String trat = tratamentoUsuarioService.tratamentoConversacional(u, null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S. Os cálculos JÁ FORAM FEITOS — NÃO recalcule.\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Confirme o crédito e o novo saldo primeiro.\\n\"\n + \"2. Mostre o custo real: quanto recebe vs quanto devolve e juros.\\n\"\n + \"3. Diga quanto da renda fica comprometido e por quantos meses.\\n\"\n + \"4. Se parcela estimada, avise para informar valor exato do contrato.\\n\"\n + \"5. Linguagem simples. Trate por \\\"\" + trat + \"\\\". Máximo 2 emojis.\\n\"\n + \"6. Feche lembrando que dá pra acompanhar na aba Transações.\\n\";\n String fallback = montarFallbackRegistroEmprestimo(userId, resultado, brl);\n return gerarTexto(userId, system, \"DADOS CALCULADOS (não recalcule, apenas redija):\\n\" + dados, fallback, AITaskType.FINANCIAL_ADVISOR);\n }\n\n private String montarFallbackRegistroEmprestimo(Long userId, ResultadoRegistroEmprestimo r, NumberFormat brl) {\n String voc = tratamentoUsuarioService.vocativoPorId(userId, usuarioRepository);\n StringBuilder sb = new StringBuilder();\n sb.append(tratamentoUsuarioService.feitoExclamacao(voc)).append(\"Creditei *\").append(brl.format(r.getValorTomado())).append(\"*\");\n if (r.getContaNome() != null) {\n sb.append(\" na *\").append(r.getContaNome()).append(\"*\");\n }\n if (r.getNovoSaldoConta() != null) {\n sb.append(\" — saldo agora *\").append(brl.format(r.getNovoSaldoConta())).append(\"*\");\n }\n sb.append(\".\\n\");\n sb.append(\"São *\").append(r.getQuantidadeParcelas()).append(\"x de \")\n .append(brl.format(r.getValorParcela()));\n if (r.isParcelaEstimada()) {\n sb.append(\" _(estimada — me passe o valor exato do contrato pra ajustar)_\");\n }\n sb.append(\".\\n\");\n if (r.getJurosTotais() != null && r.getTotalAPagar() != null) {\n sb.append(\"Você recebe \").append(brl.format(r.getValorTomado()))\n .append(\", mas devolve \").append(brl.format(r.getTotalAPagar()))\n .append(\" — \").append(brl.format(r.getJurosTotais())).append(\" de juros.\\n\");\n }\n if (r.getPctRendaComprometidaDepois() != null) {\n sb.append(\"Comprometimento da renda: *\").append(r.getPctRendaComprometidaDepois()).append(\"%*.\\n\");\n }\n sb.append(\"Acompanhe as parcelas em *Transações*.\");\n return sb.toString();\n }\n\n private String montarFallbackDeterministico(Long userId, ResultadoConselho r, NumberFormat brl) {\n String voc = tratamentoUsuarioService.vocativoPorId(userId, usuarioRepository);\n String selo = switch (r.getVeredito() != null ? r.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"🔴 *[RISCO ALTO]*\";\n case SEGURO -> \"🟢 *[PODE IR]*\";\n case ATENCAO -> \"🟡 *[ATENÇÃO]*\";\n };\n StringBuilder sb = new StringBuilder(selo).append(\" \");\n sb.append(\"Sobre \").append(r.getDescricaoItem() != null ? r.getDescricaoItem() : \"essa operação\").append(\": \");\n if (r.getCustoTotal() != null) {\n sb.append(\"custo total \").append(brl.format(r.getCustoTotal())).append(\". \");\n }\n if (r.getJurosTotais() != null && r.getJurosTotais().compareTo(BigDecimal.ZERO) > 0) {\n sb.append(\"Juros: \").append(brl.format(r.getJurosTotais())).append(\". \");\n }\n if (r.getPercentualRendaComprometida() != null) {\n sb.append(\"Compromete \").append(r.getPercentualRendaComprometida()).append(\"% da sua renda. \");\n }\n if (r.getSaldoAposCompra() != null) {\n sb.append(\"Saldo após compra: \").append(brl.format(r.getSaldoAposCompra())).append(\". \");\n }\n sb.append(tratamentoUsuarioService.palavraFinal(voc));\n return sb.toString();\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Roteamento inteligente por tipo de tarefa — fallback apenas dentro da categoria.\n */\n private <T> T executeAIRequestWithFallback(\n AITaskType taskType,\n Long userId,\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n String tokenEstimateInput,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(taskType, userId, null, cfg, canUseProvider, tokenEstimateInput, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AITaskType taskType,\n Long userId,\n Double temperature,\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n String tokenEstimateInput,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n try {\n return aiRouterService.route(\n AiRouterRequestContext.builder().userId(userId).temperature(temperature).build(),\n taskType,\n cfg,\n canUseProvider,\n tokenEstimateInput != null ? tokenEstimateInput : \"\",\n attempt::execute,\n failureMessagePrefix\n );\n } catch (RuntimeException e) {\n if (e.getMessage() != null && e.getMessage().contains(\"nenhum provedor elegível\")) {\n appendMissingPlatformProvidersHint(cfg, e);\n }\n throw e;\n }\n }\n\n private void appendMissingPlatformProvidersHint(AiProvidersConfig cfg, RuntimeException e) {\n // mantém mensagens de diagnóstico legadas quando chaves de plataforma faltam\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n log.debug(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n List<String> userOrder = cfg != null && cfg.getProviderOrder() != null\n ? cfg.getProviderOrder()\n : List.of();\n List<AiProviderType> out = new ArrayList<>();\n for (String name : userOrder) {\n AiProviderType t = AiProviderType.fromString(name);\n if (t != null && !out.contains(t)) {\n out.add(t);\n }\n }\n for (AiProviderType t : AiProviderOrder.canonicalTypes()) {\n if (!out.contains(t)) {\n out.add(t);\n }\n }\n return out;\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n /** OCR de cupom/fatura/contracheque — Gemini e Claude também suportam visão. */\n private boolean canVisionForTask(AITaskType taskType, AiProvidersConfig cfg, AiProviderType p) {\n if (taskType == AITaskType.OCR_RECEIPT || taskType == AITaskType.OCR_INVOICE || taskType == AITaskType.OCR_PAYSLIP) {\n return switch (p) {\n case GEMINI, CLAUDE, GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n default -> false;\n };\n }\n return canVision(cfg, p);\n }\n\n private JsonNode parseVisionForProvider(\n AiProviderType provider,\n AiProvidersConfig cfg,\n String model,\n String systemPrompt,\n String userPrompt,\n String imageSource,\n double temperature\n ) throws Exception {\n return switch (provider) {\n case GEMINI -> parseGeminiVisionJson(model, systemPrompt, userPrompt, imageSource, temperature);\n case CLAUDE -> parseClaudeVisionJson(model, systemPrompt, userPrompt, imageSource);\n default -> parseVisionOpenAiCompatible(\n provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt, imageSource, temperature);\n };\n }\n\n private record ImageInline(String mimeType, String base64) {}\n\n private ImageInline parseImageSource(String imageSource) {\n if (imageSource == null || imageSource.isBlank()) {\n throw new RuntimeException(\"Imagem não informada\");\n }\n if (imageSource.startsWith(\"data:\")) {\n int comma = imageSource.indexOf(',');\n if (comma < 0) {\n throw new RuntimeException(\"Data URL inválida\");\n }\n String meta = imageSource.substring(5, comma);\n String mime = meta.contains(\";\") ? meta.substring(0, meta.indexOf(';')) : meta;\n return new ImageInline(mime.isBlank() ? \"image/jpeg\" : mime, imageSource.substring(comma + 1));\n }\n throw new RuntimeException(\"Provedor requer imagem em data URL (base64)\");\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return parseChatJsonForProvider(provider, cfg, model, systemPrompt, userPrompt, 0.1);\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt, double temperature) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt, temperature);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt, temperature);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = parseRestTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt, double temperature) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", temperature,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = parseRestTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseGeminiVisionJson(\n String model, String systemPrompt, String userPrompt, String imageSource, double temperature\n ) throws Exception {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n ImageInline img = parseImageSource(imageSource);\n\n List<Map<String, Object>> parts = new ArrayList<>();\n parts.add(Map.of(\"text\", userPrompt));\n parts.add(Map.of(\"inline_data\", Map.of(\"mime_type\", img.mimeType(), \"data\", img.base64())));\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"systemInstruction\", Map.of(\"parts\", List.of(Map.of(\"text\", systemPrompt))));\n payload.put(\"contents\", List.of(Map.of(\"role\", \"user\", \"parts\", parts)));\n payload.put(\"generationConfig\", Map.of(\n \"temperature\", temperature,\n \"responseMimeType\", \"application/json\"\n ));\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = parseRestTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n log.info(\"IA processada via GEMINI (visão/json)\");\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseClaudeVisionJson(String model, String systemPrompt, String userPrompt, String imageSource)\n throws Exception {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n ImageInline img = parseImageSource(imageSource);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"image\", \"source\", Map.of(\n \"type\", \"base64\",\n \"media_type\", img.mimeType(),\n \"data\", img.base64()\n )),\n Map.of(\"type\", \"text\", \"text\", userPrompt)\n )\n )));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = parseRestTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (visão/json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n return parseCommandOpenAiCompatible(providerName, key, providerBaseUrl, model, systemPrompt, userPrompt, 0.1);\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, double temperature) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama, temperature);\n ResponseEntity<String> response = callOpenAiCompatibleForParse(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n return parseVisionOpenAiCompatible(providerName, key, providerBaseUrl, model, systemPrompt, userPrompt, imageSource, 0.1);\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource, double temperature) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama, temperature);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n return buildChatJsonPayload(model, systemPrompt, userPrompt, useOpenAiJsonMode, 0.1);\n }\n\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode, double temperature\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", temperature);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n return buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, useOpenAiJsonMode, 0.1);\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode, double temperature\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", temperature);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n return callOpenAiCompatibleForParse(providerBaseUrl, key, payload, false);\n }\n\n private ResponseEntity<String> callOpenAiCompatibleForParse(String providerBaseUrl, String key, Map<String, Object> payload) {\n return callOpenAiCompatibleForParse(providerBaseUrl, key, payload, true);\n }\n\n private ResponseEntity<String> callOpenAiCompatibleForParse(\n String providerBaseUrl, String key, Map<String, Object> payload, boolean parse\n ) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n RestTemplate rt = parse ? parseRestTemplate : restTemplate;\n return rt.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "7e847a5800704bef1bcb26978e6c313777dd7874": {"t": 1785752795.876167, "n": 80879, "h": "9db0c48742016e40", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.ResultadoConselho;\nimport com.consumoesperto.model.ResultadoRegistroEmprestimo;\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.model.Veredito;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.jarvis.TratamentoUsuarioService;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.text.NumberFormat;\nimport java.util.ArrayList;\nimport java.util.Locale;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n private final TratamentoUsuarioService tratamentoUsuarioService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private com.consumoesperto.service.jarvis.JarvisContextoFinanceiroCacheService jarvisContextoFinanceiroCacheService;\n\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private com.consumoesperto.service.jarvis.JarvisConversaJanelaService jarvisConversaJanelaService;\n\n @Value(\"${consumoesperto.jarvis.performance.parse-read-timeout-ms:25000}\")\n private int parseReadTimeoutMs;\n\n private RestTemplate restTemplate;\n private RestTemplate parseRestTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n\n SimpleClientHttpRequestFactory parseFactory = new SimpleClientHttpRequestFactory();\n parseFactory.setConnectTimeout(Math.max(3_000, Math.min(aiHttpConnectTimeoutMs, 10_000)));\n parseFactory.setReadTimeout(Math.max(5_000, parseReadTimeoutMs));\n parseRestTemplate = new RestTemplate(parseFactory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null) {\n return \"\";\n }\n try {\n if (jarvisContextoFinanceiroCacheService != null) {\n return jarvisContextoFinanceiroCacheService.montarBlocoContexto(userId);\n }\n return \"\";\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n return parseCommand(inputText, userId, 0.1);\n }\n\n /** Retry estruturado: use temperatura 0 para resposta determinística. */\n public JsonNode parseCommand(String inputText, Long userId, double temperature) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|CREATE_FIXED_EXPENSE|CREATE_SUBSCRIPTION|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_FINANCIAL_ADVICE|RECORD_CONSIGNMENT_LOAN|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|LIST_FATURAS|LIST_ACCOUNTS|TRANSFER_BETWEEN_ACCOUNTS|LIST_TRANSACTIONS|LIST_CATEGORIES|LIST_METAS|GET_REPORT_SUMMARY|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|SET_INCOME_PROFILE|CONFIRM_FISCAL_PROVISION|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|LIST_SUBSCRIPTIONS|TOGGLE_SUBSCRIPTION|START_TUTORIAL|STOP_TUTORIAL|TUTORIAL_STEP|GREETING|UNKNOWN), \" +\n \"valorTomado (valor recebido do empréstimo consignado), nomeConta (apelido da conta que recebeu), \" +\n \"contaOrigem (apelido/nome da conta de origem na transferência), contaDestino (apelido/nome da conta de destino na transferência), \" +\n \"mes (1-12, opcional — consultas de extrato/orçamento), ano (ex.: 2026, opcional), tipo (DESPESA|RECEITA, opcional — filtro de extrato), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento|despesa_fixa|assinatura), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"subscriptionActive (true para ativar/reativar assinatura cadastrada; false para desativar/pausar), \" +\n \"tipoPerfil (CONTRACHEQUE|RECEBIMENTO_UNICO|FLUXO_DIARIO — perfil híbrido de renda), \" +\n \"salarioBruto, descontosHolerite (total ou use descontosFixos em updates), diaRecebimento (1-31, CONTRACHEQUE), \" +\n \"valorLiquidoFixo, diaRecebimentoFixo (RECEBIMENTO_UNICO), metaFaturamentoMensal (FLUXO_DIARIO), \" +\n \"tipoOperacao (COMPRA_AVISTA|COMPRA_PARCELADA|EMPRESTIMO|CONSIGNADO|FINANCIAMENTO — conselho de compra/empréstimo), \" +\n \"valorTotal (valor do bem ou valor tomado no empréstimo), valorParcela (valor de cada parcela), \" +\n \"quantidadeParcelas (número de parcelas), descricaoItem (ex.: moto, tênis, consignado), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"nome_normalizado (nome corrigido da entidade identificada), entidade_ambigua (true se abreviação dupla), \" +\n \"entidade_desconhecida (true se não reconhecer), opcoes_entidade (array de nomes quando ambíguo), \" +\n \"confianca (0-1), errorMessage, \" +\n \"memoriasSugeridas (array OPCIONAL de memórias de longo prazo dignas de registro que o usuário declarou \" +\n \"na mensagem: planos de gasto futuro, preferências duradouras ou fatos pessoais-financeiros relevantes. \" +\n \"Cada item: {texto (frase curta e autocontida em 3ª pessoa), tipo (PLANO_FUTURO|PREFERENCIA|FATO), \" +\n \"valor (número em reais se citado), mesAlvo (1-12 se citado), anoAlvo (ex.: 2026 se citado)}. \" +\n \"Ex.: 'mês que vem vou viajar e gastar uns 1500' → [{\\\"texto\\\":\\\"Planeja viajar e gastar cerca de R$ 1500\\\",\" +\n \"\\\"tipo\\\":\\\"PLANO_FUTURO\\\",\\\"valor\\\":1500,\\\"mesAlvo\\\":<mês seguinte>}]. \" +\n \"NÃO inclua comandos operacionais (registrar despesa, consultar saldo), pedidos pontuais nem dados sensíveis; \" +\n \"na dúvida, devolva array vazio. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\";\n if (jarvisConversaJanelaService != null && userId != null) {\n String hist = jarvisConversaJanelaService.montarBlocoHistorico(userId);\n if (hist != null && !hist.isBlank()) {\n userPrompt = hist + \"\\n\" + userPrompt;\n }\n }\n userPrompt += \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount; categoryName quando o estabelecimento \" +\n \"implicar categoria (ex.: ubr/uber→Transporte, mrcdo/mercado→Alimentação); nome_normalizado nas entidades.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Despesa com beneficiário PIX no extrato do cartão (ex.: 'pix Maria Silva 3340 no cartão Itaú em 2x'): paymentMethod=CARTAO, \" +\n \"bank ou cardName = cartão citado (Itaú/Nubank/Azul), description = nome do beneficiário; NÃO use paymentMethod=CONTA nem cardName=beneficiário. \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor, dia_vencimento (1-31).\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se pedir conselho sobre compra, empréstimo, consignado ou financiamento (ex.: 'vale a pena comprar moto de 15k parcelada?', \" +\n \"'consignado de 10 mil em 24x de 550 o que acha?', 'posso comprar um tênis de 800 hoje?', 'devo pegar esse empréstimo?'): \" +\n \"action GET_FINANCIAL_ADVICE; preencha tipoOperacao, valorTotal, valorParcela, quantidadeParcelas, descricaoItem. \" +\n \"Normalização numérica: '15k'/'15 mil'→15000; '10.000'→10000; '24x de 550'→quantidadeParcelas=24, valorParcela=550; \" +\n \"'550 por mês durante 2 anos'→valorParcela=550, quantidadeParcelas=24. \" +\n \"Se citar parcelas/consignado/financiamento→operação parcelada; 'à vista'/'hoje'/'agora' sem parcelas→COMPRA_AVISTA. \" +\n \"NÃO calcule juros nem veredito — apenas extraia parâmetros.\\n\" +\n \"- Se REGISTRAR empréstimo/consignado JÁ CONTRATADO (passado: 'fiz', 'contratei', 'peguei', 'caiu na conta'), \" +\n \"NÃO use GET_FINANCIAL_ADVICE — use action RECORD_CONSIGNMENT_LOAN; preencha valorTomado (ou valorTotal), \" +\n \"quantidadeParcelas, valorParcela se citado, nomeConta/accountName/bank se citar conta.\\n\" +\n \"- Se perguntar SE VALE A PENA / opinião sobre empréstimo futuro: GET_FINANCIAL_ADVICE.\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se perguntar valor/total das faturas de todos os cartões (ex.: 'em quanto tá as faturas dos meus cartões?', \" +\n \"'quanto estão as faturas?', 'valor de cada fatura dos cartões'): action LIST_FATURAS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura de um cartão específico, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se pedir saldos das contas, patrimônio em contas ou quanto tem nas contas (ex.: 'quanto eu tenho nas contas?', \" +\n \"'saldos das contas', 'meu patrimônio', 'lista minhas contas', 'saldo do nu', 'saldo da conta Nubank'): \" +\n \"action LIST_ACCOUNTS; preencher accountName ou bank com o apelido citado; nome_normalizado com o nome corrigido.\\n\" +\n \"- Se pedir transferir/mover/passar valor entre contas bancárias (ex.: 'transfere 100 do Itaú pro Nubank', \" +\n \"'passa 50 reais da conta Inter para a poupança', 'manda 200 da corrente pra carteira'): action TRANSFER_BETWEEN_ACCOUNTS; \" +\n \"preencher amount (valor), contaOrigem e contaDestino com os apelidos citados; NÃO use CREATE_EXPENSE para transferências internas.\\n\" +\n \"- Se pedir extrato, últimos gastos/receitas ou listar transações (ex.: 'quais foram meus últimos gastos?', \" +\n \"'lista os pix que recebi em junho', 'o que gastei na categoria mercado esse mês?'): action LIST_TRANSACTIONS; \" +\n \"preencher mes/ano ou reportMonth/reportYear quando citar período; tipo=DESPESA ou RECEITA se filtrar; categoryName/categoria se citar categoria.\\n\" +\n \"- Se pedir listar categorias cadastradas (ex.: 'quais categorias eu tenho?', 'lista minhas categorias'): action LIST_CATEGORIES.\\n\" +\n \"- Se pedir estado das metas/objetivos (ex.: 'como estão minhas metas?', 'quais são os meus objetivos de economia?'): action LIST_METAS.\\n\" +\n \"- Se pedir resumo de orçamento, limites por categoria ou gasto vs limite (ex.: 'quanto gastei do meu orçamento?', \" +\n \"'resumo de gastos do orçamento', 'meus limites de categoria'): action GET_REPORT_SUMMARY; mes/ano se citar mês.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação', \" +\n \"'apague despesa fixa aluguel', 'remova assinatura Netflix'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento | despesa_fixa | assinatura; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se configurar *perfil de renda híbrido* (contracheque, recebimento único ou fluxo diário), ex.: \" +\n \"'minha renda agora é fluxo diário com meta de 5000', 'recebo um pix único de 4000 todo dia 10', \" +\n \"'meu salário bruto é 6000 com descontos de 800 dia 5': action SET_INCOME_PROFILE; \" +\n \"tipoPerfil = CONTRACHEQUE | RECEBIMENTO_UNICO | FLUXO_DIARIO; \" +\n \"CONTRACHEQUE: salarioBruto, descontosHolerite (total) ou descontosFixos em updates, diaRecebimento; \" +\n \"RECEBIMENTO_UNICO: valorLiquidoFixo, diaRecebimentoFixo; FLUXO_DIARIO: metaFaturamentoMensal (opcional). \" +\n \"Prefira SET_INCOME_PROFILE quando a frase citar explicitamente o *tipo* de trabalhador/perfil.\\n\" +\n \"- Se confirmar que provisão fiscal caiu na conta (ex.: 'confirma o 13', 'primeira parcela do 13 caiu', \" +\n \"'restituição do IR entrou na conta', 'efetiva o décimo terceiro'): action CONFIRM_FISCAL_PROVISION; \" +\n \"description = termo citado (13, restituição IR, etc.); accountName/bank se citar conta específica.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Listar assinaturas cadastradas/monitoradas (ex.: 'quais minhas assinaturas?', 'lista assinaturas'): action LIST_SUBSCRIPTIONS.\\n\" +\n \"- Cadastrar assinatura recorrente manualmente (ex.: 'assinei o plano do Spotify por 24,90 por mês', \" +\n \"'cadastra a assinatura da Netflix de 55,90 todo dia 15'): action CREATE_SUBSCRIPTION; description = nome; amount = valor mensal; dueDay = dia de cobrança (1-31).\\n\" +\n \"- Cadastrar despesa fixa/obrigação mensal (ex.: 'cadastra a despesa fixa de aluguel valor 1200 vencimento todo dia 10', \" +\n \"'salve essa despesa fixa de 250 para internet dia 10'): action CREATE_FIXED_EXPENSE; description = nome; amount = valor; dueDay = dia de vencimento.\\n\" +\n \"- Ativar ou desativar assinatura cadastrada (ex.: 'desative a assinatura da Netflix', 'pause a academia', 'reative o Spotify'): \" +\n \"action TOGGLE_SUBSCRIPTION; description ou searchPhrase = nome da assinatura; subscriptionActive=false para desativar/pausar, true para ativar.\\n\" +\n \"- Tutorial interativo opt-in (guia de uso do sistema): action START_TUTORIAL para frases como \" +\n \"'tutorial', 'como usar', 'me ensina a usar o sistema', 'como funciona isso aqui', 'ajuda', 'help', 'nao sei usar'.\\n\" +\n \"- START_TUTORIAL também quando o utilizador perguntar sobre uma aba/tela específica — preencha reportMonth com o capítulo (1-5) e entregue o capítulo direto, sem menu:\\n\" +\n \" 'como uso a aba de contas?' → reportMonth=1 · 'me explica a tela de renda' → reportMonth=4 · \" +\n \"'o que e a aba de metas?' → reportMonth=5 · 'nao entendo a aba de cartoes' → reportMonth=3 · \" +\n \"'como funciona a aba de transacoes?' → reportMonth=2.\\n\" +\n \"- Encerrar tutorial ativo: action STOP_TUTORIAL para 'sair', 'parar', 'desligar', 'cancelar', 'encerrar tutorial', 'chega', 'voltar ao normal'.\\n\" +\n \"- Navegar capítulo do tutorial (1 a 5) quando o utilizador está no guia: action TUTORIAL_STEP; preencher reportMonth com o número do capítulo (1-5) se útil.\\n\" +\n \"REGRAS DE TOLERÂNCIA A LINGUAGEM:\\n\" +\n \"Ao interpretar a mensagem do usuário, aplique normalizações ANTES de classificar a intenção.\\n\" +\n \"ABREVIAÇÕES FINANCEIRAS: nu/nub/nuba/nubank→Nubank; ita/itau/itá→Itaú; bb/bradesco→Bradesco; inter→Banco Inter; \" +\n \"xp/xpi→XP; cef/caixa/cx→Caixa; sntdr/santander→Santander.\\n\" +\n \"ESTABELECIMENTOS: mrcdo/mercd/mkt/feira/sacolão→Mercado/Alimentação; ubr/uber/99→Transporte; \" +\n \"ifood/ifd/rappi→Alimentação/Delivery; farm/farmci/drog→Farmácia; posto/gasolina/combust→Combustível; \" +\n \"academia/acad/gym→Academia; netf/netflix/spotify/prime→Assinaturas/Lazer.\\n\" +\n \"INFORMAIS: tô no vermelho/zerado/liso→saldo crítico; sobrou/sobra→consulta saldo; fechei/como tá o mês→relatório; \" +\n \"queimei/estourei→orçamento excedido; guardei/separei→poupança/meta; parcelei/em x vezes→parcelado.\\n\" +\n \"REGRAS: (1) corrija erros óbvios pelo contexto; (2) abreviação dupla→entidade_ambigua+opcoes_entidade; \" +\n \"(3) não invente entidade→entidade_desconhecida; (4) preencha nome_normalizado com o nome corrigido.\\n\" +\n \"Intenção GREETING: ative quando a mensagem for APENAS uma saudação informal brasileira \" +\n \"(oi, olá, iae, iai, e aí, salve, opa, fala, beleza, tudo bem, bom dia, boa tarde, boa noite), sem pedido financeiro.\\n\" +\n \"REGRA IMPORTANTE: se além da saudação houver um comando financeiro na mesma frase \" +\n \"(ex.: 'oi jarvis, quanto tenho na conta?'), classifique pela INTENÇÃO FINANCEIRA principal \" +\n \"(LIST_ACCOUNTS, CREATE_EXPENSE, etc.), NÃO como GREETING.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n final double tempFinal = temperature;\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user(), tempFinal);\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n return analisarImagemNotaFiscal(imageBytes, contentType, userId, 0.1);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId, double temperature) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId, temperature);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n return analisarImagemNotaFiscalConteudo(imageSource, userId, 0.1);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId, double temperature) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n final double tempFinal = temperature;\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource, tempFinal);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n /** Visão computacional para boleto bancário ou QR Code Pix (agendamento de pagamento). */\n public JsonNode analisarImagemBoletoPix(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para leitura de boleto/Pix\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você extrai dados de boleto bancário brasileiro ou QR Code Pix para agendamento de pagamento. \"\n + \"Retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise a imagem e extraia: \"\n + \"tipo (BOLETO|PIX|CUPOM_FISCAL|OUTRO), beneficiario (string), valor (número decimal), \"\n + \"dataVencimento (yyyy-MM-dd), linhaDigitavel ou codigoBarrasOuPix (se visível), \"\n + \"confianca (0 a 1), erro (string opcional). \"\n + \"Use tipo CUPOM_FISCAL se for nota fiscal de compra, não boleto. \"\n + \"Se não for possível ler com segurança, preencha erro e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId, systemPrompt, userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, dataUrl);\n },\n \"Falha leitura boleto/Pix em todos provedores: \"\n );\n log.info(\"[VISION-LOG] Boleto/Pix userId={} tipo={} confianca={}\",\n userId, out.path(\"tipo\").asText(\"\"), out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJson(userId, systemPrompt, userPrompt, 0.1);\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt, double temperature) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false, temperature);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonDocumento(userId, systemPrompt, userPrompt, 0.1);\n }\n\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt, double temperature) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true, temperature);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento, double temperature) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n final double tempFinal = temperature;\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal, tempFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Narra o conselho financeiro — a IA só redige; todos os números já foram calculados em Java.\n */\n public String narrarConselho(Long userId, ResultadoConselho resultado) {\n if (resultado == null) {\n String voc = tratamentoUsuarioService.vocativoPorId(userId, usuarioRepository);\n return tratamentoUsuarioService.prefixoVocativo(voc)\n + \"não consegui montar o cenário agora. Tenta de novo com valor e parcelas, se houver.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n String vereditoTxt = switch (resultado.getVeredito() != null ? resultado.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"RISCO_ALTO\";\n case SEGURO -> \"SEGURO\";\n case ATENCAO -> \"ATENCAO\";\n };\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Veredito: \").append(vereditoTxt).append(\"\\n\");\n dados.append(\"- Item: \").append(resultado.getDescricaoItem() != null ? resultado.getDescricaoItem() : \"item\").append(\"\\n\");\n if (resultado.getCustoTotal() != null) {\n dados.append(\"- Custo total: \").append(brl.format(resultado.getCustoTotal())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensal() != null && resultado.getTaxaJurosAnual() != null) {\n dados.append(\"- Taxa de juros: \").append(resultado.getTaxaJurosMensal()).append(\"% ao mês / \")\n .append(resultado.getTaxaJurosAnual()).append(\"% ao ano\\n\");\n }\n if (resultado.getPercentualRendaComprometida() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPercentualRendaComprometida()).append(\"%\\n\");\n }\n if (resultado.getSaldoAposCompra() != null) {\n dados.append(\"- Saldo após a compra: \").append(brl.format(resultado.getSaldoAposCompra())).append(\"\\n\");\n }\n if (resultado.getReservaMesesApos() != null) {\n dados.append(\"- Reserva após a compra: \").append(resultado.getReservaMesesApos()).append(\" meses\\n\");\n }\n if (resultado.getMesesReservaAtual() != null) {\n dados.append(\"- Reserva atual (Escudo): \").append(resultado.getMesesReservaAtual()).append(\" meses\\n\");\n }\n if (resultado.getComparacaoMercado() != null) {\n dados.append(\"- Comparação com o mercado: \").append(resultado.getComparacaoMercado());\n if (resultado.getTaxaMercadoReferencia() != null) {\n dados.append(\" (referência \").append(resultado.getTaxaMercadoReferencia()).append(\"% a.a.)\");\n }\n dados.append(\"\\n\");\n }\n if (resultado.isAvisoSemValorTomado()) {\n dados.append(\"- Aviso: valor tomado/à vista não informado — juros não calculados\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String trat = tratamentoUsuarioService.tratamentoConversacional(u, null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S., estrategista financeiro pessoal. Os cálculos JÁ FORAM FEITOS.\\n\"\n + \"Sua tarefa é APENAS transformar os dados abaixo em uma resposta clara, direta e humana.\\n\"\n + \"NÃO recalcule nada. NÃO invente números. Use exatamente os valores fornecidos.\\n\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Comece com o selo do veredito: 🔴 *[RISCO ALTO]* | 🟡 *[ATENÇÃO]* | 🟢 *[PODE IR]*\\n\"\n + \"2. Explique o PORQUÊ em linguagem simples, sem jargão técnico.\\n\"\n + \"3. Se houver juros, mostre o impacto concreto no bolso.\\n\"\n + \"4. Para parcelas, diga quantos % da renda ficam comprometidos.\\n\"\n + \"5. Em decisões grandes, feche lembrando que a decisão é de \" + trat + \".\\n\"\n + \"6. Tom: direto, honesto, protetor do bolso. Trate por \\\"\" + trat + \"\\\".\\n\"\n + \"7. Máximo 2 emojis além do selo.\\n\"\n + \"8. Orientação baseada nos dados cadastrados — não é garantia nem consultoria profissional.\\n\";\n String userPrompt = \"DADOS CALCULADOS:\\n\" + dados;\n String fallback = montarFallbackDeterministico(userId, resultado, brl);\n return gerarTexto(userId, system, userPrompt, fallback);\n }\n\n /**\n * Narra o registro de empréstimo consignado — números já calculados em Java.\n */\n public String narrarRegistroEmprestimo(Long userId, ResultadoRegistroEmprestimo resultado) {\n if (resultado == null) {\n String voc = tratamentoUsuarioService.vocativoPorId(userId, usuarioRepository);\n return tratamentoUsuarioService.prefixoVocativo(voc)\n + \"não consegui concluir o registro do empréstimo agora.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Valor creditado: \").append(brl.format(resultado.getValorTomado())).append(\"\\n\");\n if (resultado.getContaNome() != null) {\n dados.append(\"- Conta: \").append(resultado.getContaNome()).append(\"\\n\");\n }\n if (resultado.getNovoSaldoConta() != null) {\n dados.append(\"- Novo saldo da conta: \").append(brl.format(resultado.getNovoSaldoConta())).append(\"\\n\");\n }\n dados.append(\"- Parcela: \").append(brl.format(resultado.getValorParcela()));\n if (resultado.isParcelaEstimada()) {\n dados.append(\" (estimada pela taxa média de mercado)\");\n }\n dados.append(\"\\n\");\n dados.append(\"- Nº de parcelas: \").append(resultado.getQuantidadeParcelas());\n if (resultado.getDataPrimeiraParcela() != null && resultado.getDataUltimaParcela() != null) {\n dados.append(\" (\").append(resultado.getDataPrimeiraParcela())\n .append(\" até \").append(resultado.getDataUltimaParcela()).append(\")\\n\");\n } else {\n dados.append(\"\\n\");\n }\n if (resultado.getTotalAPagar() != null) {\n dados.append(\"- Total a pagar: \").append(brl.format(resultado.getTotalAPagar())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensalPct() != null && resultado.getTaxaJurosAnualPct() != null) {\n dados.append(\"- Taxa efetiva: \").append(resultado.getTaxaJurosMensalPct())\n .append(\"% a.m. / \").append(resultado.getTaxaJurosAnualPct()).append(\"% a.a.\\n\");\n }\n if (resultado.getPctRendaComprometidaDepois() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPctRendaComprometidaDepois()).append(\"%\\n\");\n }\n if (resultado.getRendaLivreAntes() != null && resultado.getRendaLivreDepois() != null) {\n dados.append(\"- Renda livre: era \").append(brl.format(resultado.getRendaLivreAntes()))\n .append(\", ficou \").append(brl.format(resultado.getRendaLivreDepois())).append(\"\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String trat = tratamentoUsuarioService.tratamentoConversacional(u, null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S. Os cálculos JÁ FORAM FEITOS — NÃO recalcule.\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Confirme o crédito e o novo saldo primeiro.\\n\"\n + \"2. Mostre o custo real: quanto recebe vs quanto devolve e juros.\\n\"\n + \"3. Diga quanto da renda fica comprometido e por quantos meses.\\n\"\n + \"4. Se parcela estimada, avise para informar valor exato do contrato.\\n\"\n + \"5. Linguagem simples. Trate por \\\"\" + trat + \"\\\". Máximo 2 emojis.\\n\"\n + \"6. Feche lembrando que dá pra acompanhar na aba Transações.\\n\";\n String fallback = montarFallbackRegistroEmprestimo(userId, resultado, brl);\n return gerarTexto(userId, system, \"DADOS CALCULADOS (não recalcule, apenas redija):\\n\" + dados, fallback);\n }\n\n private String montarFallbackRegistroEmprestimo(Long userId, ResultadoRegistroEmprestimo r, NumberFormat brl) {\n String voc = tratamentoUsuarioService.vocativoPorId(userId, usuarioRepository);\n StringBuilder sb = new StringBuilder();\n sb.append(tratamentoUsuarioService.feitoExclamacao(voc)).append(\"Creditei *\").append(brl.format(r.getValorTomado())).append(\"*\");\n if (r.getContaNome() != null) {\n sb.append(\" na *\").append(r.getContaNome()).append(\"*\");\n }\n if (r.getNovoSaldoConta() != null) {\n sb.append(\" — saldo agora *\").append(brl.format(r.getNovoSaldoConta())).append(\"*\");\n }\n sb.append(\".\\n\");\n sb.append(\"São *\").append(r.getQuantidadeParcelas()).append(\"x de \")\n .append(brl.format(r.getValorParcela()));\n if (r.isParcelaEstimada()) {\n sb.append(\" _(estimada — me passe o valor exato do contrato pra ajustar)_\");\n }\n sb.append(\".\\n\");\n if (r.getJurosTotais() != null && r.getTotalAPagar() != null) {\n sb.append(\"Você recebe \").append(brl.format(r.getValorTomado()))\n .append(\", mas devolve \").append(brl.format(r.getTotalAPagar()))\n .append(\" — \").append(brl.format(r.getJurosTotais())).append(\" de juros.\\n\");\n }\n if (r.getPctRendaComprometidaDepois() != null) {\n sb.append(\"Comprometimento da renda: *\").append(r.getPctRendaComprometidaDepois()).append(\"%*.\\n\");\n }\n sb.append(\"Acompanhe as parcelas em *Transações*.\");\n return sb.toString();\n }\n\n private String montarFallbackDeterministico(Long userId, ResultadoConselho r, NumberFormat brl) {\n String voc = tratamentoUsuarioService.vocativoPorId(userId, usuarioRepository);\n String selo = switch (r.getVeredito() != null ? r.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"🔴 *[RISCO ALTO]*\";\n case SEGURO -> \"🟢 *[PODE IR]*\";\n case ATENCAO -> \"🟡 *[ATENÇÃO]*\";\n };\n StringBuilder sb = new StringBuilder(selo).append(\" \");\n sb.append(\"Sobre \").append(r.getDescricaoItem() != null ? r.getDescricaoItem() : \"essa operação\").append(\": \");\n if (r.getCustoTotal() != null) {\n sb.append(\"custo total \").append(brl.format(r.getCustoTotal())).append(\". \");\n }\n if (r.getJurosTotais() != null && r.getJurosTotais().compareTo(BigDecimal.ZERO) > 0) {\n sb.append(\"Juros: \").append(brl.format(r.getJurosTotais())).append(\". \");\n }\n if (r.getPercentualRendaComprometida() != null) {\n sb.append(\"Compromete \").append(r.getPercentualRendaComprometida()).append(\"% da sua renda. \");\n }\n if (r.getSaldoAposCompra() != null) {\n sb.append(\"Saldo após compra: \").append(brl.format(r.getSaldoAposCompra())).append(\". \");\n }\n sb.append(tratamentoUsuarioService.palavraFinal(voc));\n return sb.toString();\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n List<String> userOrder = cfg != null && cfg.getProviderOrder() != null\n ? cfg.getProviderOrder()\n : List.of();\n List<AiProviderType> out = new ArrayList<>();\n for (String name : userOrder) {\n AiProviderType t = AiProviderType.fromString(name);\n if (t != null && !out.contains(t)) {\n out.add(t);\n }\n }\n for (AiProviderType t : AiProviderOrder.canonicalTypes()) {\n if (!out.contains(t)) {\n out.add(t);\n }\n }\n return out;\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return parseChatJsonForProvider(provider, cfg, model, systemPrompt, userPrompt, 0.1);\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt, double temperature) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt, temperature);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt, temperature);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = parseRestTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt, double temperature) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", temperature,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = parseRestTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n return parseCommandOpenAiCompatible(providerName, key, providerBaseUrl, model, systemPrompt, userPrompt, 0.1);\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, double temperature) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama, temperature);\n ResponseEntity<String> response = callOpenAiCompatibleForParse(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n return parseVisionOpenAiCompatible(providerName, key, providerBaseUrl, model, systemPrompt, userPrompt, imageSource, 0.1);\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource, double temperature) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama, temperature);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n return buildChatJsonPayload(model, systemPrompt, userPrompt, useOpenAiJsonMode, 0.1);\n }\n\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode, double temperature\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", temperature);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n return buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, useOpenAiJsonMode, 0.1);\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode, double temperature\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", temperature);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n return callOpenAiCompatibleForParse(providerBaseUrl, key, payload, false);\n }\n\n private ResponseEntity<String> callOpenAiCompatibleForParse(String providerBaseUrl, String key, Map<String, Object> payload) {\n return callOpenAiCompatibleForParse(providerBaseUrl, key, payload, true);\n }\n\n private ResponseEntity<String> callOpenAiCompatibleForParse(\n String providerBaseUrl, String key, Map<String, Object> payload, boolean parse\n ) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n RestTemplate rt = parse ? parseRestTemplate : restTemplate;\n return rt.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "bbffbe827bb0575797200e3face3f9282e55af32": {"t": 1785752798.5776625, "n": 77900, "h": "63589170e7376c2c", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.ResultadoConselho;\nimport com.consumoesperto.model.ResultadoRegistroEmprestimo;\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.model.Veredito;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.jarvis.TratamentoUsuarioService;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.text.NumberFormat;\nimport java.util.ArrayList;\nimport java.util.Locale;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n private final TratamentoUsuarioService tratamentoUsuarioService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private com.consumoesperto.service.jarvis.JarvisContextoFinanceiroCacheService jarvisContextoFinanceiroCacheService;\n\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private com.consumoesperto.service.jarvis.JarvisConversaJanelaService jarvisConversaJanelaService;\n\n @Value(\"${consumoesperto.jarvis.performance.parse-read-timeout-ms:25000}\")\n private int parseReadTimeoutMs;\n\n private RestTemplate restTemplate;\n private RestTemplate parseRestTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n\n SimpleClientHttpRequestFactory parseFactory = new SimpleClientHttpRequestFactory();\n parseFactory.setConnectTimeout(Math.max(3_000, Math.min(aiHttpConnectTimeoutMs, 10_000)));\n parseFactory.setReadTimeout(Math.max(5_000, parseReadTimeoutMs));\n parseRestTemplate = new RestTemplate(parseFactory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null) {\n return \"\";\n }\n try {\n if (jarvisContextoFinanceiroCacheService != null) {\n return jarvisContextoFinanceiroCacheService.montarBlocoContexto(userId);\n }\n return \"\";\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|CREATE_FIXED_EXPENSE|CREATE_SUBSCRIPTION|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_FINANCIAL_ADVICE|RECORD_CONSIGNMENT_LOAN|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|LIST_FATURAS|LIST_ACCOUNTS|TRANSFER_BETWEEN_ACCOUNTS|LIST_TRANSACTIONS|LIST_CATEGORIES|LIST_METAS|GET_REPORT_SUMMARY|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|SET_INCOME_PROFILE|CONFIRM_FISCAL_PROVISION|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|LIST_SUBSCRIPTIONS|TOGGLE_SUBSCRIPTION|START_TUTORIAL|STOP_TUTORIAL|TUTORIAL_STEP|GREETING|UNKNOWN), \" +\n \"valorTomado (valor recebido do empréstimo consignado), nomeConta (apelido da conta que recebeu), \" +\n \"contaOrigem (apelido/nome da conta de origem na transferência), contaDestino (apelido/nome da conta de destino na transferência), \" +\n \"mes (1-12, opcional — consultas de extrato/orçamento), ano (ex.: 2026, opcional), tipo (DESPESA|RECEITA, opcional — filtro de extrato), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento|despesa_fixa|assinatura), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"subscriptionActive (true para ativar/reativar assinatura cadastrada; false para desativar/pausar), \" +\n \"tipoPerfil (CONTRACHEQUE|RECEBIMENTO_UNICO|FLUXO_DIARIO — perfil híbrido de renda), \" +\n \"salarioBruto, descontosHolerite (total ou use descontosFixos em updates), diaRecebimento (1-31, CONTRACHEQUE), \" +\n \"valorLiquidoFixo, diaRecebimentoFixo (RECEBIMENTO_UNICO), metaFaturamentoMensal (FLUXO_DIARIO), \" +\n \"tipoOperacao (COMPRA_AVISTA|COMPRA_PARCELADA|EMPRESTIMO|CONSIGNADO|FINANCIAMENTO — conselho de compra/empréstimo), \" +\n \"valorTotal (valor do bem ou valor tomado no empréstimo), valorParcela (valor de cada parcela), \" +\n \"quantidadeParcelas (número de parcelas), descricaoItem (ex.: moto, tênis, consignado), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"nome_normalizado (nome corrigido da entidade identificada), entidade_ambigua (true se abreviação dupla), \" +\n \"entidade_desconhecida (true se não reconhecer), opcoes_entidade (array de nomes quando ambíguo), \" +\n \"confianca (0-1), errorMessage, \" +\n \"memoriasSugeridas (array OPCIONAL de memórias de longo prazo dignas de registro que o usuário declarou \" +\n \"na mensagem: planos de gasto futuro, preferências duradouras ou fatos pessoais-financeiros relevantes. \" +\n \"Cada item: {texto (frase curta e autocontida em 3ª pessoa), tipo (PLANO_FUTURO|PREFERENCIA|FATO), \" +\n \"valor (número em reais se citado), mesAlvo (1-12 se citado), anoAlvo (ex.: 2026 se citado)}. \" +\n \"Ex.: 'mês que vem vou viajar e gastar uns 1500' → [{\\\"texto\\\":\\\"Planeja viajar e gastar cerca de R$ 1500\\\",\" +\n \"\\\"tipo\\\":\\\"PLANO_FUTURO\\\",\\\"valor\\\":1500,\\\"mesAlvo\\\":<mês seguinte>}]. \" +\n \"NÃO inclua comandos operacionais (registrar despesa, consultar saldo), pedidos pontuais nem dados sensíveis; \" +\n \"na dúvida, devolva array vazio. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\";\n if (jarvisConversaJanelaService != null && userId != null) {\n String hist = jarvisConversaJanelaService.montarBlocoHistorico(userId);\n if (hist != null && !hist.isBlank()) {\n userPrompt = hist + \"\\n\" + userPrompt;\n }\n }\n userPrompt += \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount; categoryName quando o estabelecimento \" +\n \"implicar categoria (ex.: ubr/uber→Transporte, mrcdo/mercado→Alimentação); nome_normalizado nas entidades.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Despesa com beneficiário PIX no extrato do cartão (ex.: 'pix Maria Silva 3340 no cartão Itaú em 2x'): paymentMethod=CARTAO, \" +\n \"bank ou cardName = cartão citado (Itaú/Nubank/Azul), description = nome do beneficiário; NÃO use paymentMethod=CONTA nem cardName=beneficiário. \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor, dia_vencimento (1-31).\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se pedir conselho sobre compra, empréstimo, consignado ou financiamento (ex.: 'vale a pena comprar moto de 15k parcelada?', \" +\n \"'consignado de 10 mil em 24x de 550 o que acha?', 'posso comprar um tênis de 800 hoje?', 'devo pegar esse empréstimo?'): \" +\n \"action GET_FINANCIAL_ADVICE; preencha tipoOperacao, valorTotal, valorParcela, quantidadeParcelas, descricaoItem. \" +\n \"Normalização numérica: '15k'/'15 mil'→15000; '10.000'→10000; '24x de 550'→quantidadeParcelas=24, valorParcela=550; \" +\n \"'550 por mês durante 2 anos'→valorParcela=550, quantidadeParcelas=24. \" +\n \"Se citar parcelas/consignado/financiamento→operação parcelada; 'à vista'/'hoje'/'agora' sem parcelas→COMPRA_AVISTA. \" +\n \"NÃO calcule juros nem veredito — apenas extraia parâmetros.\\n\" +\n \"- Se REGISTRAR empréstimo/consignado JÁ CONTRATADO (passado: 'fiz', 'contratei', 'peguei', 'caiu na conta'), \" +\n \"NÃO use GET_FINANCIAL_ADVICE — use action RECORD_CONSIGNMENT_LOAN; preencha valorTomado (ou valorTotal), \" +\n \"quantidadeParcelas, valorParcela se citado, nomeConta/accountName/bank se citar conta.\\n\" +\n \"- Se perguntar SE VALE A PENA / opinião sobre empréstimo futuro: GET_FINANCIAL_ADVICE.\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se perguntar valor/total das faturas de todos os cartões (ex.: 'em quanto tá as faturas dos meus cartões?', \" +\n \"'quanto estão as faturas?', 'valor de cada fatura dos cartões'): action LIST_FATURAS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura de um cartão específico, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se pedir saldos das contas, patrimônio em contas ou quanto tem nas contas (ex.: 'quanto eu tenho nas contas?', \" +\n \"'saldos das contas', 'meu patrimônio', 'lista minhas contas', 'saldo do nu', 'saldo da conta Nubank'): \" +\n \"action LIST_ACCOUNTS; preencher accountName ou bank com o apelido citado; nome_normalizado com o nome corrigido.\\n\" +\n \"- Se pedir transferir/mover/passar valor entre contas bancárias (ex.: 'transfere 100 do Itaú pro Nubank', \" +\n \"'passa 50 reais da conta Inter para a poupança', 'manda 200 da corrente pra carteira'): action TRANSFER_BETWEEN_ACCOUNTS; \" +\n \"preencher amount (valor), contaOrigem e contaDestino com os apelidos citados; NÃO use CREATE_EXPENSE para transferências internas.\\n\" +\n \"- Se pedir extrato, últimos gastos/receitas ou listar transações (ex.: 'quais foram meus últimos gastos?', \" +\n \"'lista os pix que recebi em junho', 'o que gastei na categoria mercado esse mês?'): action LIST_TRANSACTIONS; \" +\n \"preencher mes/ano ou reportMonth/reportYear quando citar período; tipo=DESPESA ou RECEITA se filtrar; categoryName/categoria se citar categoria.\\n\" +\n \"- Se pedir listar categorias cadastradas (ex.: 'quais categorias eu tenho?', 'lista minhas categorias'): action LIST_CATEGORIES.\\n\" +\n \"- Se pedir estado das metas/objetivos (ex.: 'como estão minhas metas?', 'quais são os meus objetivos de economia?'): action LIST_METAS.\\n\" +\n \"- Se pedir resumo de orçamento, limites por categoria ou gasto vs limite (ex.: 'quanto gastei do meu orçamento?', \" +\n \"'resumo de gastos do orçamento', 'meus limites de categoria'): action GET_REPORT_SUMMARY; mes/ano se citar mês.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação', \" +\n \"'apague despesa fixa aluguel', 'remova assinatura Netflix'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento | despesa_fixa | assinatura; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se configurar *perfil de renda híbrido* (contracheque, recebimento único ou fluxo diário), ex.: \" +\n \"'minha renda agora é fluxo diário com meta de 5000', 'recebo um pix único de 4000 todo dia 10', \" +\n \"'meu salário bruto é 6000 com descontos de 800 dia 5': action SET_INCOME_PROFILE; \" +\n \"tipoPerfil = CONTRACHEQUE | RECEBIMENTO_UNICO | FLUXO_DIARIO; \" +\n \"CONTRACHEQUE: salarioBruto, descontosHolerite (total) ou descontosFixos em updates, diaRecebimento; \" +\n \"RECEBIMENTO_UNICO: valorLiquidoFixo, diaRecebimentoFixo; FLUXO_DIARIO: metaFaturamentoMensal (opcional). \" +\n \"Prefira SET_INCOME_PROFILE quando a frase citar explicitamente o *tipo* de trabalhador/perfil.\\n\" +\n \"- Se confirmar que provisão fiscal caiu na conta (ex.: 'confirma o 13', 'primeira parcela do 13 caiu', \" +\n \"'restituição do IR entrou na conta', 'efetiva o décimo terceiro'): action CONFIRM_FISCAL_PROVISION; \" +\n \"description = termo citado (13, restituição IR, etc.); accountName/bank se citar conta específica.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Listar assinaturas cadastradas/monitoradas (ex.: 'quais minhas assinaturas?', 'lista assinaturas'): action LIST_SUBSCRIPTIONS.\\n\" +\n \"- Cadastrar assinatura recorrente manualmente (ex.: 'assinei o plano do Spotify por 24,90 por mês', \" +\n \"'cadastra a assinatura da Netflix de 55,90 todo dia 15'): action CREATE_SUBSCRIPTION; description = nome; amount = valor mensal; dueDay = dia de cobrança (1-31).\\n\" +\n \"- Cadastrar despesa fixa/obrigação mensal (ex.: 'cadastra a despesa fixa de aluguel valor 1200 vencimento todo dia 10', \" +\n \"'salve essa despesa fixa de 250 para internet dia 10'): action CREATE_FIXED_EXPENSE; description = nome; amount = valor; dueDay = dia de vencimento.\\n\" +\n \"- Ativar ou desativar assinatura cadastrada (ex.: 'desative a assinatura da Netflix', 'pause a academia', 'reative o Spotify'): \" +\n \"action TOGGLE_SUBSCRIPTION; description ou searchPhrase = nome da assinatura; subscriptionActive=false para desativar/pausar, true para ativar.\\n\" +\n \"- Tutorial interativo opt-in (guia de uso do sistema): action START_TUTORIAL para frases como \" +\n \"'tutorial', 'como usar', 'me ensina a usar o sistema', 'como funciona isso aqui', 'ajuda', 'help', 'nao sei usar'.\\n\" +\n \"- START_TUTORIAL também quando o utilizador perguntar sobre uma aba/tela específica — preencha reportMonth com o capítulo (1-5) e entregue o capítulo direto, sem menu:\\n\" +\n \" 'como uso a aba de contas?' → reportMonth=1 · 'me explica a tela de renda' → reportMonth=4 · \" +\n \"'o que e a aba de metas?' → reportMonth=5 · 'nao entendo a aba de cartoes' → reportMonth=3 · \" +\n \"'como funciona a aba de transacoes?' → reportMonth=2.\\n\" +\n \"- Encerrar tutorial ativo: action STOP_TUTORIAL para 'sair', 'parar', 'desligar', 'cancelar', 'encerrar tutorial', 'chega', 'voltar ao normal'.\\n\" +\n \"- Navegar capítulo do tutorial (1 a 5) quando o utilizador está no guia: action TUTORIAL_STEP; preencher reportMonth com o número do capítulo (1-5) se útil.\\n\" +\n \"REGRAS DE TOLERÂNCIA A LINGUAGEM:\\n\" +\n \"Ao interpretar a mensagem do usuário, aplique normalizações ANTES de classificar a intenção.\\n\" +\n \"ABREVIAÇÕES FINANCEIRAS: nu/nub/nuba/nubank→Nubank; ita/itau/itá→Itaú; bb/bradesco→Bradesco; inter→Banco Inter; \" +\n \"xp/xpi→XP; cef/caixa/cx→Caixa; sntdr/santander→Santander.\\n\" +\n \"ESTABELECIMENTOS: mrcdo/mercd/mkt/feira/sacolão→Mercado/Alimentação; ubr/uber/99→Transporte; \" +\n \"ifood/ifd/rappi→Alimentação/Delivery; farm/farmci/drog→Farmácia; posto/gasolina/combust→Combustível; \" +\n \"academia/acad/gym→Academia; netf/netflix/spotify/prime→Assinaturas/Lazer.\\n\" +\n \"INFORMAIS: tô no vermelho/zerado/liso→saldo crítico; sobrou/sobra→consulta saldo; fechei/como tá o mês→relatório; \" +\n \"queimei/estourei→orçamento excedido; guardei/separei→poupança/meta; parcelei/em x vezes→parcelado.\\n\" +\n \"REGRAS: (1) corrija erros óbvios pelo contexto; (2) abreviação dupla→entidade_ambigua+opcoes_entidade; \" +\n \"(3) não invente entidade→entidade_desconhecida; (4) preencha nome_normalizado com o nome corrigido.\\n\" +\n \"Intenção GREETING: ative quando a mensagem for APENAS uma saudação informal brasileira \" +\n \"(oi, olá, iae, iai, e aí, salve, opa, fala, beleza, tudo bem, bom dia, boa tarde, boa noite), sem pedido financeiro.\\n\" +\n \"REGRA IMPORTANTE: se além da saudação houver um comando financeiro na mesma frase \" +\n \"(ex.: 'oi jarvis, quanto tenho na conta?'), classifique pela INTENÇÃO FINANCEIRA principal \" +\n \"(LIST_ACCOUNTS, CREATE_EXPENSE, etc.), NÃO como GREETING.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n /** Visão computacional para boleto bancário ou QR Code Pix (agendamento de pagamento). */\n public JsonNode analisarImagemBoletoPix(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para leitura de boleto/Pix\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você extrai dados de boleto bancário brasileiro ou QR Code Pix para agendamento de pagamento. \"\n + \"Retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise a imagem e extraia: \"\n + \"tipo (BOLETO|PIX|CUPOM_FISCAL|OUTRO), beneficiario (string), valor (número decimal), \"\n + \"dataVencimento (yyyy-MM-dd), linhaDigitavel ou codigoBarrasOuPix (se visível), \"\n + \"confianca (0 a 1), erro (string opcional). \"\n + \"Use tipo CUPOM_FISCAL se for nota fiscal de compra, não boleto. \"\n + \"Se não for possível ler com segurança, preencha erro e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId, systemPrompt, userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, dataUrl);\n },\n \"Falha leitura boleto/Pix em todos provedores: \"\n );\n log.info(\"[VISION-LOG] Boleto/Pix userId={} tipo={} confianca={}\",\n userId, out.path(\"tipo\").asText(\"\"), out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Narra o conselho financeiro — a IA só redige; todos os números já foram calculados em Java.\n */\n public String narrarConselho(Long userId, ResultadoConselho resultado) {\n if (resultado == null) {\n String voc = tratamentoUsuarioService.vocativoPorId(userId, usuarioRepository);\n return tratamentoUsuarioService.prefixoVocativo(voc)\n + \"não consegui montar o cenário agora. Tenta de novo com valor e parcelas, se houver.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n String vereditoTxt = switch (resultado.getVeredito() != null ? resultado.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"RISCO_ALTO\";\n case SEGURO -> \"SEGURO\";\n case ATENCAO -> \"ATENCAO\";\n };\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Veredito: \").append(vereditoTxt).append(\"\\n\");\n dados.append(\"- Item: \").append(resultado.getDescricaoItem() != null ? resultado.getDescricaoItem() : \"item\").append(\"\\n\");\n if (resultado.getCustoTotal() != null) {\n dados.append(\"- Custo total: \").append(brl.format(resultado.getCustoTotal())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensal() != null && resultado.getTaxaJurosAnual() != null) {\n dados.append(\"- Taxa de juros: \").append(resultado.getTaxaJurosMensal()).append(\"% ao mês / \")\n .append(resultado.getTaxaJurosAnual()).append(\"% ao ano\\n\");\n }\n if (resultado.getPercentualRendaComprometida() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPercentualRendaComprometida()).append(\"%\\n\");\n }\n if (resultado.getSaldoAposCompra() != null) {\n dados.append(\"- Saldo após a compra: \").append(brl.format(resultado.getSaldoAposCompra())).append(\"\\n\");\n }\n if (resultado.getReservaMesesApos() != null) {\n dados.append(\"- Reserva após a compra: \").append(resultado.getReservaMesesApos()).append(\" meses\\n\");\n }\n if (resultado.getMesesReservaAtual() != null) {\n dados.append(\"- Reserva atual (Escudo): \").append(resultado.getMesesReservaAtual()).append(\" meses\\n\");\n }\n if (resultado.getComparacaoMercado() != null) {\n dados.append(\"- Comparação com o mercado: \").append(resultado.getComparacaoMercado());\n if (resultado.getTaxaMercadoReferencia() != null) {\n dados.append(\" (referência \").append(resultado.getTaxaMercadoReferencia()).append(\"% a.a.)\");\n }\n dados.append(\"\\n\");\n }\n if (resultado.isAvisoSemValorTomado()) {\n dados.append(\"- Aviso: valor tomado/à vista não informado — juros não calculados\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String trat = tratamentoUsuarioService.tratamentoConversacional(u, null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S., estrategista financeiro pessoal. Os cálculos JÁ FORAM FEITOS.\\n\"\n + \"Sua tarefa é APENAS transformar os dados abaixo em uma resposta clara, direta e humana.\\n\"\n + \"NÃO recalcule nada. NÃO invente números. Use exatamente os valores fornecidos.\\n\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Comece com o selo do veredito: 🔴 *[RISCO ALTO]* | 🟡 *[ATENÇÃO]* | 🟢 *[PODE IR]*\\n\"\n + \"2. Explique o PORQUÊ em linguagem simples, sem jargão técnico.\\n\"\n + \"3. Se houver juros, mostre o impacto concreto no bolso.\\n\"\n + \"4. Para parcelas, diga quantos % da renda ficam comprometidos.\\n\"\n + \"5. Em decisões grandes, feche lembrando que a decisão é de \" + trat + \".\\n\"\n + \"6. Tom: direto, honesto, protetor do bolso. Trate por \\\"\" + trat + \"\\\".\\n\"\n + \"7. Máximo 2 emojis além do selo.\\n\"\n + \"8. Orientação baseada nos dados cadastrados — não é garantia nem consultoria profissional.\\n\";\n String userPrompt = \"DADOS CALCULADOS:\\n\" + dados;\n String fallback = montarFallbackDeterministico(userId, resultado, brl);\n return gerarTexto(userId, system, userPrompt, fallback);\n }\n\n /**\n * Narra o registro de empréstimo consignado — números já calculados em Java.\n */\n public String narrarRegistroEmprestimo(Long userId, ResultadoRegistroEmprestimo resultado) {\n if (resultado == null) {\n String voc = tratamentoUsuarioService.vocativoPorId(userId, usuarioRepository);\n return tratamentoUsuarioService.prefixoVocativo(voc)\n + \"não consegui concluir o registro do empréstimo agora.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Valor creditado: \").append(brl.format(resultado.getValorTomado())).append(\"\\n\");\n if (resultado.getContaNome() != null) {\n dados.append(\"- Conta: \").append(resultado.getContaNome()).append(\"\\n\");\n }\n if (resultado.getNovoSaldoConta() != null) {\n dados.append(\"- Novo saldo da conta: \").append(brl.format(resultado.getNovoSaldoConta())).append(\"\\n\");\n }\n dados.append(\"- Parcela: \").append(brl.format(resultado.getValorParcela()));\n if (resultado.isParcelaEstimada()) {\n dados.append(\" (estimada pela taxa média de mercado)\");\n }\n dados.append(\"\\n\");\n dados.append(\"- Nº de parcelas: \").append(resultado.getQuantidadeParcelas());\n if (resultado.getDataPrimeiraParcela() != null && resultado.getDataUltimaParcela() != null) {\n dados.append(\" (\").append(resultado.getDataPrimeiraParcela())\n .append(\" até \").append(resultado.getDataUltimaParcela()).append(\")\\n\");\n } else {\n dados.append(\"\\n\");\n }\n if (resultado.getTotalAPagar() != null) {\n dados.append(\"- Total a pagar: \").append(brl.format(resultado.getTotalAPagar())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensalPct() != null && resultado.getTaxaJurosAnualPct() != null) {\n dados.append(\"- Taxa efetiva: \").append(resultado.getTaxaJurosMensalPct())\n .append(\"% a.m. / \").append(resultado.getTaxaJurosAnualPct()).append(\"% a.a.\\n\");\n }\n if (resultado.getPctRendaComprometidaDepois() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPctRendaComprometidaDepois()).append(\"%\\n\");\n }\n if (resultado.getRendaLivreAntes() != null && resultado.getRendaLivreDepois() != null) {\n dados.append(\"- Renda livre: era \").append(brl.format(resultado.getRendaLivreAntes()))\n .append(\", ficou \").append(brl.format(resultado.getRendaLivreDepois())).append(\"\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String trat = tratamentoUsuarioService.tratamentoConversacional(u, null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S. Os cálculos JÁ FORAM FEITOS — NÃO recalcule.\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Confirme o crédito e o novo saldo primeiro.\\n\"\n + \"2. Mostre o custo real: quanto recebe vs quanto devolve e juros.\\n\"\n + \"3. Diga quanto da renda fica comprometido e por quantos meses.\\n\"\n + \"4. Se parcela estimada, avise para informar valor exato do contrato.\\n\"\n + \"5. Linguagem simples. Trate por \\\"\" + trat + \"\\\". Máximo 2 emojis.\\n\"\n + \"6. Feche lembrando que dá pra acompanhar na aba Transações.\\n\";\n String fallback = montarFallbackRegistroEmprestimo(userId, resultado, brl);\n return gerarTexto(userId, system, \"DADOS CALCULADOS (não recalcule, apenas redija):\\n\" + dados, fallback);\n }\n\n private String montarFallbackRegistroEmprestimo(Long userId, ResultadoRegistroEmprestimo r, NumberFormat brl) {\n String voc = tratamentoUsuarioService.vocativoPorId(userId, usuarioRepository);\n StringBuilder sb = new StringBuilder();\n sb.append(tratamentoUsuarioService.feitoExclamacao(voc)).append(\"Creditei *\").append(brl.format(r.getValorTomado())).append(\"*\");\n if (r.getContaNome() != null) {\n sb.append(\" na *\").append(r.getContaNome()).append(\"*\");\n }\n if (r.getNovoSaldoConta() != null) {\n sb.append(\" — saldo agora *\").append(brl.format(r.getNovoSaldoConta())).append(\"*\");\n }\n sb.append(\".\\n\");\n sb.append(\"São *\").append(r.getQuantidadeParcelas()).append(\"x de \")\n .append(brl.format(r.getValorParcela()));\n if (r.isParcelaEstimada()) {\n sb.append(\" _(estimada — me passe o valor exato do contrato pra ajustar)_\");\n }\n sb.append(\".\\n\");\n if (r.getJurosTotais() != null && r.getTotalAPagar() != null) {\n sb.append(\"Você recebe \").append(brl.format(r.getValorTomado()))\n .append(\", mas devolve \").append(brl.format(r.getTotalAPagar()))\n .append(\" — \").append(brl.format(r.getJurosTotais())).append(\" de juros.\\n\");\n }\n if (r.getPctRendaComprometidaDepois() != null) {\n sb.append(\"Comprometimento da renda: *\").append(r.getPctRendaComprometidaDepois()).append(\"%*.\\n\");\n }\n sb.append(\"Acompanhe as parcelas em *Transações*.\");\n return sb.toString();\n }\n\n private String montarFallbackDeterministico(Long userId, ResultadoConselho r, NumberFormat brl) {\n String voc = tratamentoUsuarioService.vocativoPorId(userId, usuarioRepository);\n String selo = switch (r.getVeredito() != null ? r.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"🔴 *[RISCO ALTO]*\";\n case SEGURO -> \"🟢 *[PODE IR]*\";\n case ATENCAO -> \"🟡 *[ATENÇÃO]*\";\n };\n StringBuilder sb = new StringBuilder(selo).append(\" \");\n sb.append(\"Sobre \").append(r.getDescricaoItem() != null ? r.getDescricaoItem() : \"essa operação\").append(\": \");\n if (r.getCustoTotal() != null) {\n sb.append(\"custo total \").append(brl.format(r.getCustoTotal())).append(\". \");\n }\n if (r.getJurosTotais() != null && r.getJurosTotais().compareTo(BigDecimal.ZERO) > 0) {\n sb.append(\"Juros: \").append(brl.format(r.getJurosTotais())).append(\". \");\n }\n if (r.getPercentualRendaComprometida() != null) {\n sb.append(\"Compromete \").append(r.getPercentualRendaComprometida()).append(\"% da sua renda. \");\n }\n if (r.getSaldoAposCompra() != null) {\n sb.append(\"Saldo após compra: \").append(brl.format(r.getSaldoAposCompra())).append(\". \");\n }\n sb.append(tratamentoUsuarioService.palavraFinal(voc));\n return sb.toString();\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n List<String> userOrder = cfg != null && cfg.getProviderOrder() != null\n ? cfg.getProviderOrder()\n : List.of();\n List<AiProviderType> out = new ArrayList<>();\n for (String name : userOrder) {\n AiProviderType t = AiProviderType.fromString(name);\n if (t != null && !out.contains(t)) {\n out.add(t);\n }\n }\n for (AiProviderType t : AiProviderOrder.canonicalTypes()) {\n if (!out.contains(t)) {\n out.add(t);\n }\n }\n return out;\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = parseRestTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = parseRestTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama);\n ResponseEntity<String> response = callOpenAiCompatibleForParse(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n return callOpenAiCompatibleForParse(providerBaseUrl, key, payload, false);\n }\n\n private ResponseEntity<String> callOpenAiCompatibleForParse(String providerBaseUrl, String key, Map<String, Object> payload) {\n return callOpenAiCompatibleForParse(providerBaseUrl, key, payload, true);\n }\n\n private ResponseEntity<String> callOpenAiCompatibleForParse(\n String providerBaseUrl, String key, Map<String, Object> payload, boolean parse\n ) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n RestTemplate rt = parse ? parseRestTemplate : restTemplate;\n return rt.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "bc4ba64b640d7f025c188ce56354135abe86b822": {"t": 1785752800.3204114, "n": 74850, "h": "dd5c0135fd288060", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.ResultadoConselho;\nimport com.consumoesperto.model.ResultadoRegistroEmprestimo;\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.model.Veredito;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.text.NumberFormat;\nimport java.util.ArrayList;\nimport java.util.Locale;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private JarvisContextoFinanceiroService jarvisContextoFinanceiroService;\n\n private RestTemplate restTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null || jarvisContextoFinanceiroService == null) {\n return \"\";\n }\n try {\n return jarvisContextoFinanceiroService.montarBlocoContexto(userId);\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|CREATE_FIXED_EXPENSE|CREATE_SUBSCRIPTION|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_FINANCIAL_ADVICE|RECORD_CONSIGNMENT_LOAN|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|LIST_FATURAS|LIST_ACCOUNTS|TRANSFER_BETWEEN_ACCOUNTS|LIST_TRANSACTIONS|LIST_CATEGORIES|LIST_METAS|GET_REPORT_SUMMARY|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|SET_INCOME_PROFILE|CONFIRM_FISCAL_PROVISION|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|LIST_SUBSCRIPTIONS|TOGGLE_SUBSCRIPTION|START_TUTORIAL|STOP_TUTORIAL|TUTORIAL_STEP|GREETING|UNKNOWN), \" +\n \"valorTomado (valor recebido do empréstimo consignado), nomeConta (apelido da conta que recebeu), \" +\n \"contaOrigem (apelido/nome da conta de origem na transferência), contaDestino (apelido/nome da conta de destino na transferência), \" +\n \"mes (1-12, opcional — consultas de extrato/orçamento), ano (ex.: 2026, opcional), tipo (DESPESA|RECEITA, opcional — filtro de extrato), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento|despesa_fixa|assinatura), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"subscriptionActive (true para ativar/reativar assinatura cadastrada; false para desativar/pausar), \" +\n \"tipoPerfil (CONTRACHEQUE|RECEBIMENTO_UNICO|FLUXO_DIARIO — perfil híbrido de renda), \" +\n \"salarioBruto, descontosHolerite (total ou use descontosFixos em updates), diaRecebimento (1-31, CONTRACHEQUE), \" +\n \"valorLiquidoFixo, diaRecebimentoFixo (RECEBIMENTO_UNICO), metaFaturamentoMensal (FLUXO_DIARIO), \" +\n \"tipoOperacao (COMPRA_AVISTA|COMPRA_PARCELADA|EMPRESTIMO|CONSIGNADO|FINANCIAMENTO — conselho de compra/empréstimo), \" +\n \"valorTotal (valor do bem ou valor tomado no empréstimo), valorParcela (valor de cada parcela), \" +\n \"quantidadeParcelas (número de parcelas), descricaoItem (ex.: moto, tênis, consignado), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"nome_normalizado (nome corrigido da entidade identificada), entidade_ambigua (true se abreviação dupla), \" +\n \"entidade_desconhecida (true se não reconhecer), opcoes_entidade (array de nomes quando ambíguo), \" +\n \"confianca (0-1), errorMessage, \" +\n \"memoriasSugeridas (array OPCIONAL de memórias de longo prazo dignas de registro que o usuário declarou \" +\n \"na mensagem: planos de gasto futuro, preferências duradouras ou fatos pessoais-financeiros relevantes. \" +\n \"Cada item: {texto (frase curta e autocontida em 3ª pessoa), tipo (PLANO_FUTURO|PREFERENCIA|FATO), \" +\n \"valor (número em reais se citado), mesAlvo (1-12 se citado), anoAlvo (ex.: 2026 se citado)}. \" +\n \"Ex.: 'mês que vem vou viajar e gastar uns 1500' → [{\\\"texto\\\":\\\"Planeja viajar e gastar cerca de R$ 1500\\\",\" +\n \"\\\"tipo\\\":\\\"PLANO_FUTURO\\\",\\\"valor\\\":1500,\\\"mesAlvo\\\":<mês seguinte>}]. \" +\n \"NÃO inclua comandos operacionais (registrar despesa, consultar saldo), pedidos pontuais nem dados sensíveis; \" +\n \"na dúvida, devolva array vazio. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount; categoryName quando o estabelecimento \" +\n \"implicar categoria (ex.: ubr/uber→Transporte, mrcdo/mercado→Alimentação); nome_normalizado nas entidades.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Despesa com beneficiário PIX no extrato do cartão (ex.: 'pix Maria Silva 3340 no cartão Itaú em 2x'): paymentMethod=CARTAO, \" +\n \"bank ou cardName = cartão citado (Itaú/Nubank/Azul), description = nome do beneficiário; NÃO use paymentMethod=CONTA nem cardName=beneficiário. \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor, dia_vencimento (1-31).\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se pedir conselho sobre compra, empréstimo, consignado ou financiamento (ex.: 'vale a pena comprar moto de 15k parcelada?', \" +\n \"'consignado de 10 mil em 24x de 550 o que acha?', 'posso comprar um tênis de 800 hoje?', 'devo pegar esse empréstimo?'): \" +\n \"action GET_FINANCIAL_ADVICE; preencha tipoOperacao, valorTotal, valorParcela, quantidadeParcelas, descricaoItem. \" +\n \"Normalização numérica: '15k'/'15 mil'→15000; '10.000'→10000; '24x de 550'→quantidadeParcelas=24, valorParcela=550; \" +\n \"'550 por mês durante 2 anos'→valorParcela=550, quantidadeParcelas=24. \" +\n \"Se citar parcelas/consignado/financiamento→operação parcelada; 'à vista'/'hoje'/'agora' sem parcelas→COMPRA_AVISTA. \" +\n \"NÃO calcule juros nem veredito — apenas extraia parâmetros.\\n\" +\n \"- Se REGISTRAR empréstimo/consignado JÁ CONTRATADO (passado: 'fiz', 'contratei', 'peguei', 'caiu na conta'), \" +\n \"NÃO use GET_FINANCIAL_ADVICE — use action RECORD_CONSIGNMENT_LOAN; preencha valorTomado (ou valorTotal), \" +\n \"quantidadeParcelas, valorParcela se citado, nomeConta/accountName/bank se citar conta.\\n\" +\n \"- Se perguntar SE VALE A PENA / opinião sobre empréstimo futuro: GET_FINANCIAL_ADVICE.\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se perguntar valor/total das faturas de todos os cartões (ex.: 'em quanto tá as faturas dos meus cartões?', \" +\n \"'quanto estão as faturas?', 'valor de cada fatura dos cartões'): action LIST_FATURAS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura de um cartão específico, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se pedir saldos das contas, patrimônio em contas ou quanto tem nas contas (ex.: 'quanto eu tenho nas contas?', \" +\n \"'saldos das contas', 'meu patrimônio', 'lista minhas contas', 'saldo do nu', 'saldo da conta Nubank'): \" +\n \"action LIST_ACCOUNTS; preencher accountName ou bank com o apelido citado; nome_normalizado com o nome corrigido.\\n\" +\n \"- Se pedir transferir/mover/passar valor entre contas bancárias (ex.: 'transfere 100 do Itaú pro Nubank', \" +\n \"'passa 50 reais da conta Inter para a poupança', 'manda 200 da corrente pra carteira'): action TRANSFER_BETWEEN_ACCOUNTS; \" +\n \"preencher amount (valor), contaOrigem e contaDestino com os apelidos citados; NÃO use CREATE_EXPENSE para transferências internas.\\n\" +\n \"- Se pedir extrato, últimos gastos/receitas ou listar transações (ex.: 'quais foram meus últimos gastos?', \" +\n \"'lista os pix que recebi em junho', 'o que gastei na categoria mercado esse mês?'): action LIST_TRANSACTIONS; \" +\n \"preencher mes/ano ou reportMonth/reportYear quando citar período; tipo=DESPESA ou RECEITA se filtrar; categoryName/categoria se citar categoria.\\n\" +\n \"- Se pedir listar categorias cadastradas (ex.: 'quais categorias eu tenho?', 'lista minhas categorias'): action LIST_CATEGORIES.\\n\" +\n \"- Se pedir estado das metas/objetivos (ex.: 'como estão minhas metas?', 'quais são os meus objetivos de economia?'): action LIST_METAS.\\n\" +\n \"- Se pedir resumo de orçamento, limites por categoria ou gasto vs limite (ex.: 'quanto gastei do meu orçamento?', \" +\n \"'resumo de gastos do orçamento', 'meus limites de categoria'): action GET_REPORT_SUMMARY; mes/ano se citar mês.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação', \" +\n \"'apague despesa fixa aluguel', 'remova assinatura Netflix'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento | despesa_fixa | assinatura; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se configurar *perfil de renda híbrido* (contracheque, recebimento único ou fluxo diário), ex.: \" +\n \"'minha renda agora é fluxo diário com meta de 5000', 'recebo um pix único de 4000 todo dia 10', \" +\n \"'meu salário bruto é 6000 com descontos de 800 dia 5': action SET_INCOME_PROFILE; \" +\n \"tipoPerfil = CONTRACHEQUE | RECEBIMENTO_UNICO | FLUXO_DIARIO; \" +\n \"CONTRACHEQUE: salarioBruto, descontosHolerite (total) ou descontosFixos em updates, diaRecebimento; \" +\n \"RECEBIMENTO_UNICO: valorLiquidoFixo, diaRecebimentoFixo; FLUXO_DIARIO: metaFaturamentoMensal (opcional). \" +\n \"Prefira SET_INCOME_PROFILE quando a frase citar explicitamente o *tipo* de trabalhador/perfil.\\n\" +\n \"- Se confirmar que provisão fiscal caiu na conta (ex.: 'confirma o 13', 'primeira parcela do 13 caiu', \" +\n \"'restituição do IR entrou na conta', 'efetiva o décimo terceiro'): action CONFIRM_FISCAL_PROVISION; \" +\n \"description = termo citado (13, restituição IR, etc.); accountName/bank se citar conta específica.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Listar assinaturas cadastradas/monitoradas (ex.: 'quais minhas assinaturas?', 'lista assinaturas'): action LIST_SUBSCRIPTIONS.\\n\" +\n \"- Cadastrar assinatura recorrente manualmente (ex.: 'assinei o plano do Spotify por 24,90 por mês', \" +\n \"'cadastra a assinatura da Netflix de 55,90 todo dia 15'): action CREATE_SUBSCRIPTION; description = nome; amount = valor mensal; dueDay = dia de cobrança (1-31).\\n\" +\n \"- Cadastrar despesa fixa/obrigação mensal (ex.: 'cadastra a despesa fixa de aluguel valor 1200 vencimento todo dia 10', \" +\n \"'salve essa despesa fixa de 250 para internet dia 10'): action CREATE_FIXED_EXPENSE; description = nome; amount = valor; dueDay = dia de vencimento.\\n\" +\n \"- Ativar ou desativar assinatura cadastrada (ex.: 'desative a assinatura da Netflix', 'pause a academia', 'reative o Spotify'): \" +\n \"action TOGGLE_SUBSCRIPTION; description ou searchPhrase = nome da assinatura; subscriptionActive=false para desativar/pausar, true para ativar.\\n\" +\n \"- Tutorial interativo opt-in (guia de uso do sistema): action START_TUTORIAL para frases como \" +\n \"'tutorial', 'como usar', 'me ensina a usar o sistema', 'como funciona isso aqui', 'ajuda', 'help', 'nao sei usar'.\\n\" +\n \"- START_TUTORIAL também quando o utilizador perguntar sobre uma aba/tela específica — preencha reportMonth com o capítulo (1-5) e entregue o capítulo direto, sem menu:\\n\" +\n \" 'como uso a aba de contas?' → reportMonth=1 · 'me explica a tela de renda' → reportMonth=4 · \" +\n \"'o que e a aba de metas?' → reportMonth=5 · 'nao entendo a aba de cartoes' → reportMonth=3 · \" +\n \"'como funciona a aba de transacoes?' → reportMonth=2.\\n\" +\n \"- Encerrar tutorial ativo: action STOP_TUTORIAL para 'sair', 'parar', 'desligar', 'cancelar', 'encerrar tutorial', 'chega', 'voltar ao normal'.\\n\" +\n \"- Navegar capítulo do tutorial (1 a 5) quando o utilizador está no guia: action TUTORIAL_STEP; preencher reportMonth com o número do capítulo (1-5) se útil.\\n\" +\n \"REGRAS DE TOLERÂNCIA A LINGUAGEM:\\n\" +\n \"Ao interpretar a mensagem do usuário, aplique normalizações ANTES de classificar a intenção.\\n\" +\n \"ABREVIAÇÕES FINANCEIRAS: nu/nub/nuba/nubank→Nubank; ita/itau/itá→Itaú; bb/bradesco→Bradesco; inter→Banco Inter; \" +\n \"xp/xpi→XP; cef/caixa/cx→Caixa; sntdr/santander→Santander.\\n\" +\n \"ESTABELECIMENTOS: mrcdo/mercd/mkt/feira/sacolão→Mercado/Alimentação; ubr/uber/99→Transporte; \" +\n \"ifood/ifd/rappi→Alimentação/Delivery; farm/farmci/drog→Farmácia; posto/gasolina/combust→Combustível; \" +\n \"academia/acad/gym→Academia; netf/netflix/spotify/prime→Assinaturas/Lazer.\\n\" +\n \"INFORMAIS: tô no vermelho/zerado/liso→saldo crítico; sobrou/sobra→consulta saldo; fechei/como tá o mês→relatório; \" +\n \"queimei/estourei→orçamento excedido; guardei/separei→poupança/meta; parcelei/em x vezes→parcelado.\\n\" +\n \"REGRAS: (1) corrija erros óbvios pelo contexto; (2) abreviação dupla→entidade_ambigua+opcoes_entidade; \" +\n \"(3) não invente entidade→entidade_desconhecida; (4) preencha nome_normalizado com o nome corrigido.\\n\" +\n \"Intenção GREETING: ative quando a mensagem for APENAS uma saudação informal brasileira \" +\n \"(oi, olá, iae, iai, e aí, salve, opa, fala, beleza, tudo bem, bom dia, boa tarde, boa noite), sem pedido financeiro.\\n\" +\n \"REGRA IMPORTANTE: se além da saudação houver um comando financeiro na mesma frase \" +\n \"(ex.: 'oi jarvis, quanto tenho na conta?'), classifique pela INTENÇÃO FINANCEIRA principal \" +\n \"(LIST_ACCOUNTS, CREATE_EXPENSE, etc.), NÃO como GREETING.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n /** Visão computacional para boleto bancário ou QR Code Pix (agendamento de pagamento). */\n public JsonNode analisarImagemBoletoPix(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para leitura de boleto/Pix\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você extrai dados de boleto bancário brasileiro ou QR Code Pix para agendamento de pagamento. \"\n + \"Retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise a imagem e extraia: \"\n + \"tipo (BOLETO|PIX|CUPOM_FISCAL|OUTRO), beneficiario (string), valor (número decimal), \"\n + \"dataVencimento (yyyy-MM-dd), linhaDigitavel ou codigoBarrasOuPix (se visível), \"\n + \"confianca (0 a 1), erro (string opcional). \"\n + \"Use tipo CUPOM_FISCAL se for nota fiscal de compra, não boleto. \"\n + \"Se não for possível ler com segurança, preencha erro e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId, systemPrompt, userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, dataUrl);\n },\n \"Falha leitura boleto/Pix em todos provedores: \"\n );\n log.info(\"[VISION-LOG] Boleto/Pix userId={} tipo={} confianca={}\",\n userId, out.path(\"tipo\").asText(\"\"), out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Narra o conselho financeiro — a IA só redige; todos os números já foram calculados em Java.\n */\n public String narrarConselho(Long userId, ResultadoConselho resultado) {\n if (resultado == null) {\n return \"Chefe, não consegui montar o cenário agora. Tenta de novo com valor e parcelas, se houver.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n String vereditoTxt = switch (resultado.getVeredito() != null ? resultado.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"RISCO_ALTO\";\n case SEGURO -> \"SEGURO\";\n case ATENCAO -> \"ATENCAO\";\n };\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Veredito: \").append(vereditoTxt).append(\"\\n\");\n dados.append(\"- Item: \").append(resultado.getDescricaoItem() != null ? resultado.getDescricaoItem() : \"item\").append(\"\\n\");\n if (resultado.getCustoTotal() != null) {\n dados.append(\"- Custo total: \").append(brl.format(resultado.getCustoTotal())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensal() != null && resultado.getTaxaJurosAnual() != null) {\n dados.append(\"- Taxa de juros: \").append(resultado.getTaxaJurosMensal()).append(\"% ao mês / \")\n .append(resultado.getTaxaJurosAnual()).append(\"% ao ano\\n\");\n }\n if (resultado.getPercentualRendaComprometida() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPercentualRendaComprometida()).append(\"%\\n\");\n }\n if (resultado.getSaldoAposCompra() != null) {\n dados.append(\"- Saldo após a compra: \").append(brl.format(resultado.getSaldoAposCompra())).append(\"\\n\");\n }\n if (resultado.getReservaMesesApos() != null) {\n dados.append(\"- Reserva após a compra: \").append(resultado.getReservaMesesApos()).append(\" meses\\n\");\n }\n if (resultado.getMesesReservaAtual() != null) {\n dados.append(\"- Reserva atual (Escudo): \").append(resultado.getMesesReservaAtual()).append(\" meses\\n\");\n }\n if (resultado.getComparacaoMercado() != null) {\n dados.append(\"- Comparação com o mercado: \").append(resultado.getComparacaoMercado());\n if (resultado.getTaxaMercadoReferencia() != null) {\n dados.append(\" (referência \").append(resultado.getTaxaMercadoReferencia()).append(\"% a.a.)\");\n }\n dados.append(\"\\n\");\n }\n if (resultado.isAvisoSemValorTomado()) {\n dados.append(\"- Aviso: valor tomado/à vista não informado — juros não calculados\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S., estrategista financeiro pessoal do chefe. Os cálculos JÁ FORAM FEITOS.\\n\"\n + \"Sua tarefa é APENAS transformar os dados abaixo em uma resposta clara, direta e humana.\\n\"\n + \"NÃO recalcule nada. NÃO invente números. Use exatamente os valores fornecidos.\\n\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Comece com o selo do veredito: 🔴 *[RISCO ALTO]* | 🟡 *[ATENÇÃO]* | 🟢 *[PODE IR]*\\n\"\n + \"2. Explique o PORQUÊ em linguagem simples, sem jargão técnico.\\n\"\n + \"3. Se houver juros, mostre o impacto concreto no bolso.\\n\"\n + \"4. Para parcelas, diga quantos % da renda ficam comprometidos.\\n\"\n + \"5. Em decisões grandes, feche lembrando que a decisão é do chefe.\\n\"\n + \"6. Tom: direto, honesto, protetor do bolso. Chame de chefe.\\n\"\n + \"7. Máximo 2 emojis além do selo.\\n\"\n + \"8. Orientação baseada nos dados cadastrados — não é garantia nem consultoria profissional.\\n\";\n String userPrompt = \"DADOS CALCULADOS:\\n\" + dados;\n String fallback = montarFallbackDeterministico(resultado, brl);\n return gerarTexto(userId, system, userPrompt, fallback);\n }\n\n /**\n * Narra o registro de empréstimo consignado — números já calculados em Java.\n */\n public String narrarRegistroEmprestimo(Long userId, ResultadoRegistroEmprestimo resultado) {\n if (resultado == null) {\n return \"Chefe, não consegui concluir o registro do empréstimo agora.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Valor creditado: \").append(brl.format(resultado.getValorTomado())).append(\"\\n\");\n if (resultado.getContaNome() != null) {\n dados.append(\"- Conta: \").append(resultado.getContaNome()).append(\"\\n\");\n }\n if (resultado.getNovoSaldoConta() != null) {\n dados.append(\"- Novo saldo da conta: \").append(brl.format(resultado.getNovoSaldoConta())).append(\"\\n\");\n }\n dados.append(\"- Parcela: \").append(brl.format(resultado.getValorParcela()));\n if (resultado.isParcelaEstimada()) {\n dados.append(\" (estimada pela taxa média de mercado)\");\n }\n dados.append(\"\\n\");\n dados.append(\"- Nº de parcelas: \").append(resultado.getQuantidadeParcelas());\n if (resultado.getDataPrimeiraParcela() != null && resultado.getDataUltimaParcela() != null) {\n dados.append(\" (\").append(resultado.getDataPrimeiraParcela())\n .append(\" até \").append(resultado.getDataUltimaParcela()).append(\")\\n\");\n } else {\n dados.append(\"\\n\");\n }\n if (resultado.getTotalAPagar() != null) {\n dados.append(\"- Total a pagar: \").append(brl.format(resultado.getTotalAPagar())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensalPct() != null && resultado.getTaxaJurosAnualPct() != null) {\n dados.append(\"- Taxa efetiva: \").append(resultado.getTaxaJurosMensalPct())\n .append(\"% a.m. / \").append(resultado.getTaxaJurosAnualPct()).append(\"% a.a.\\n\");\n }\n if (resultado.getPctRendaComprometidaDepois() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPctRendaComprometidaDepois()).append(\"%\\n\");\n }\n if (resultado.getRendaLivreAntes() != null && resultado.getRendaLivreDepois() != null) {\n dados.append(\"- Renda livre: era \").append(brl.format(resultado.getRendaLivreAntes()))\n .append(\", ficou \").append(brl.format(resultado.getRendaLivreDepois())).append(\"\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S. Os cálculos JÁ FORAM FEITOS — NÃO recalcule.\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Confirme o crédito e o novo saldo primeiro.\\n\"\n + \"2. Mostre o custo real: quanto recebe vs quanto devolve e juros.\\n\"\n + \"3. Diga quanto da renda fica comprometido e por quantos meses.\\n\"\n + \"4. Se parcela estimada, avise para informar valor exato do contrato.\\n\"\n + \"5. Linguagem simples. Chame de chefe. Máximo 2 emojis.\\n\"\n + \"6. Feche lembrando que dá pra acompanhar na aba Transações.\\n\";\n String fallback = montarFallbackRegistroEmprestimo(resultado, brl);\n return gerarTexto(userId, system, \"DADOS CALCULADOS (não recalcule, apenas redija):\\n\" + dados, fallback);\n }\n\n private static String montarFallbackRegistroEmprestimo(ResultadoRegistroEmprestimo r, NumberFormat brl) {\n StringBuilder sb = new StringBuilder();\n sb.append(\"Feito, chefe! Creditei *\").append(brl.format(r.getValorTomado())).append(\"*\");\n if (r.getContaNome() != null) {\n sb.append(\" na *\").append(r.getContaNome()).append(\"*\");\n }\n if (r.getNovoSaldoConta() != null) {\n sb.append(\" — saldo agora *\").append(brl.format(r.getNovoSaldoConta())).append(\"*\");\n }\n sb.append(\".\\n\");\n sb.append(\"São *\").append(r.getQuantidadeParcelas()).append(\"x de \")\n .append(brl.format(r.getValorParcela()));\n if (r.isParcelaEstimada()) {\n sb.append(\" _(estimada — me passe o valor exato do contrato pra ajustar)_\");\n }\n sb.append(\".\\n\");\n if (r.getJurosTotais() != null && r.getTotalAPagar() != null) {\n sb.append(\"Você recebe \").append(brl.format(r.getValorTomado()))\n .append(\", mas devolve \").append(brl.format(r.getTotalAPagar()))\n .append(\" — \").append(brl.format(r.getJurosTotais())).append(\" de juros.\\n\");\n }\n if (r.getPctRendaComprometidaDepois() != null) {\n sb.append(\"Comprometimento da renda: *\").append(r.getPctRendaComprometidaDepois()).append(\"%*.\\n\");\n }\n sb.append(\"Acompanhe as parcelas em *Transações*.\");\n return sb.toString();\n }\n\n private static String montarFallbackDeterministico(ResultadoConselho r, NumberFormat brl) {\n String selo = switch (r.getVeredito() != null ? r.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"🔴 *[RISCO ALTO]*\";\n case SEGURO -> \"🟢 *[PODE IR]*\";\n case ATENCAO -> \"🟡 *[ATENÇÃO]*\";\n };\n StringBuilder sb = new StringBuilder(selo).append(\" \");\n sb.append(\"Sobre \").append(r.getDescricaoItem() != null ? r.getDescricaoItem() : \"essa operação\").append(\": \");\n if (r.getCustoTotal() != null) {\n sb.append(\"custo total \").append(brl.format(r.getCustoTotal())).append(\". \");\n }\n if (r.getJurosTotais() != null && r.getJurosTotais().compareTo(BigDecimal.ZERO) > 0) {\n sb.append(\"Juros: \").append(brl.format(r.getJurosTotais())).append(\". \");\n }\n if (r.getPercentualRendaComprometida() != null) {\n sb.append(\"Compromete \").append(r.getPercentualRendaComprometida()).append(\"% da sua renda. \");\n }\n if (r.getSaldoAposCompra() != null) {\n sb.append(\"Saldo após compra: \").append(brl.format(r.getSaldoAposCompra())).append(\". \");\n }\n sb.append(\"A palavra final é sua, chefe.\");\n return sb.toString();\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "5aa69785e82716ad7404eefeb9e0d7d6df5ffeff": {"t": 1785752802.178276, "n": 74582, "h": "d5ebdbf29f7faf74", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.ResultadoConselho;\nimport com.consumoesperto.model.ResultadoRegistroEmprestimo;\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.model.Veredito;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.text.NumberFormat;\nimport java.util.ArrayList;\nimport java.util.Locale;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private JarvisContextoFinanceiroService jarvisContextoFinanceiroService;\n\n private RestTemplate restTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null || jarvisContextoFinanceiroService == null) {\n return \"\";\n }\n try {\n return jarvisContextoFinanceiroService.montarBlocoContexto(userId);\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|CREATE_FIXED_EXPENSE|CREATE_SUBSCRIPTION|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_FINANCIAL_ADVICE|RECORD_CONSIGNMENT_LOAN|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|LIST_ACCOUNTS|TRANSFER_BETWEEN_ACCOUNTS|LIST_TRANSACTIONS|LIST_CATEGORIES|LIST_METAS|GET_REPORT_SUMMARY|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|SET_INCOME_PROFILE|CONFIRM_FISCAL_PROVISION|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|LIST_SUBSCRIPTIONS|TOGGLE_SUBSCRIPTION|START_TUTORIAL|STOP_TUTORIAL|TUTORIAL_STEP|GREETING|UNKNOWN), \" +\n \"valorTomado (valor recebido do empréstimo consignado), nomeConta (apelido da conta que recebeu), \" +\n \"contaOrigem (apelido/nome da conta de origem na transferência), contaDestino (apelido/nome da conta de destino na transferência), \" +\n \"mes (1-12, opcional — consultas de extrato/orçamento), ano (ex.: 2026, opcional), tipo (DESPESA|RECEITA, opcional — filtro de extrato), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento|despesa_fixa|assinatura), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"subscriptionActive (true para ativar/reativar assinatura cadastrada; false para desativar/pausar), \" +\n \"tipoPerfil (CONTRACHEQUE|RECEBIMENTO_UNICO|FLUXO_DIARIO — perfil híbrido de renda), \" +\n \"salarioBruto, descontosHolerite (total ou use descontosFixos em updates), diaRecebimento (1-31, CONTRACHEQUE), \" +\n \"valorLiquidoFixo, diaRecebimentoFixo (RECEBIMENTO_UNICO), metaFaturamentoMensal (FLUXO_DIARIO), \" +\n \"tipoOperacao (COMPRA_AVISTA|COMPRA_PARCELADA|EMPRESTIMO|CONSIGNADO|FINANCIAMENTO — conselho de compra/empréstimo), \" +\n \"valorTotal (valor do bem ou valor tomado no empréstimo), valorParcela (valor de cada parcela), \" +\n \"quantidadeParcelas (número de parcelas), descricaoItem (ex.: moto, tênis, consignado), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"nome_normalizado (nome corrigido da entidade identificada), entidade_ambigua (true se abreviação dupla), \" +\n \"entidade_desconhecida (true se não reconhecer), opcoes_entidade (array de nomes quando ambíguo), \" +\n \"confianca (0-1), errorMessage, \" +\n \"memoriasSugeridas (array OPCIONAL de memórias de longo prazo dignas de registro que o usuário declarou \" +\n \"na mensagem: planos de gasto futuro, preferências duradouras ou fatos pessoais-financeiros relevantes. \" +\n \"Cada item: {texto (frase curta e autocontida em 3ª pessoa), tipo (PLANO_FUTURO|PREFERENCIA|FATO), \" +\n \"valor (número em reais se citado), mesAlvo (1-12 se citado), anoAlvo (ex.: 2026 se citado)}. \" +\n \"Ex.: 'mês que vem vou viajar e gastar uns 1500' → [{\\\"texto\\\":\\\"Planeja viajar e gastar cerca de R$ 1500\\\",\" +\n \"\\\"tipo\\\":\\\"PLANO_FUTURO\\\",\\\"valor\\\":1500,\\\"mesAlvo\\\":<mês seguinte>}]. \" +\n \"NÃO inclua comandos operacionais (registrar despesa, consultar saldo), pedidos pontuais nem dados sensíveis; \" +\n \"na dúvida, devolva array vazio. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount; categoryName quando o estabelecimento \" +\n \"implicar categoria (ex.: ubr/uber→Transporte, mrcdo/mercado→Alimentação); nome_normalizado nas entidades.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Despesa com beneficiário PIX no extrato do cartão (ex.: 'pix Maria Silva 3340 no cartão Itaú em 2x'): paymentMethod=CARTAO, \" +\n \"bank ou cardName = cartão citado (Itaú/Nubank/Azul), description = nome do beneficiário; NÃO use paymentMethod=CONTA nem cardName=beneficiário. \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor, dia_vencimento (1-31).\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se pedir conselho sobre compra, empréstimo, consignado ou financiamento (ex.: 'vale a pena comprar moto de 15k parcelada?', \" +\n \"'consignado de 10 mil em 24x de 550 o que acha?', 'posso comprar um tênis de 800 hoje?', 'devo pegar esse empréstimo?'): \" +\n \"action GET_FINANCIAL_ADVICE; preencha tipoOperacao, valorTotal, valorParcela, quantidadeParcelas, descricaoItem. \" +\n \"Normalização numérica: '15k'/'15 mil'→15000; '10.000'→10000; '24x de 550'→quantidadeParcelas=24, valorParcela=550; \" +\n \"'550 por mês durante 2 anos'→valorParcela=550, quantidadeParcelas=24. \" +\n \"Se citar parcelas/consignado/financiamento→operação parcelada; 'à vista'/'hoje'/'agora' sem parcelas→COMPRA_AVISTA. \" +\n \"NÃO calcule juros nem veredito — apenas extraia parâmetros.\\n\" +\n \"- Se REGISTRAR empréstimo/consignado JÁ CONTRATADO (passado: 'fiz', 'contratei', 'peguei', 'caiu na conta'), \" +\n \"NÃO use GET_FINANCIAL_ADVICE — use action RECORD_CONSIGNMENT_LOAN; preencha valorTomado (ou valorTotal), \" +\n \"quantidadeParcelas, valorParcela se citado, nomeConta/accountName/bank se citar conta.\\n\" +\n \"- Se perguntar SE VALE A PENA / opinião sobre empréstimo futuro: GET_FINANCIAL_ADVICE.\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se pedir saldos das contas, patrimônio em contas ou quanto tem nas contas (ex.: 'quanto eu tenho nas contas?', \" +\n \"'saldos das contas', 'meu patrimônio', 'lista minhas contas', 'saldo do nu', 'saldo da conta Nubank'): \" +\n \"action LIST_ACCOUNTS; preencher accountName ou bank com o apelido citado; nome_normalizado com o nome corrigido.\\n\" +\n \"- Se pedir transferir/mover/passar valor entre contas bancárias (ex.: 'transfere 100 do Itaú pro Nubank', \" +\n \"'passa 50 reais da conta Inter para a poupança', 'manda 200 da corrente pra carteira'): action TRANSFER_BETWEEN_ACCOUNTS; \" +\n \"preencher amount (valor), contaOrigem e contaDestino com os apelidos citados; NÃO use CREATE_EXPENSE para transferências internas.\\n\" +\n \"- Se pedir extrato, últimos gastos/receitas ou listar transações (ex.: 'quais foram meus últimos gastos?', \" +\n \"'lista os pix que recebi em junho', 'o que gastei na categoria mercado esse mês?'): action LIST_TRANSACTIONS; \" +\n \"preencher mes/ano ou reportMonth/reportYear quando citar período; tipo=DESPESA ou RECEITA se filtrar; categoryName/categoria se citar categoria.\\n\" +\n \"- Se pedir listar categorias cadastradas (ex.: 'quais categorias eu tenho?', 'lista minhas categorias'): action LIST_CATEGORIES.\\n\" +\n \"- Se pedir estado das metas/objetivos (ex.: 'como estão minhas metas?', 'quais são os meus objetivos de economia?'): action LIST_METAS.\\n\" +\n \"- Se pedir resumo de orçamento, limites por categoria ou gasto vs limite (ex.: 'quanto gastei do meu orçamento?', \" +\n \"'resumo de gastos do orçamento', 'meus limites de categoria'): action GET_REPORT_SUMMARY; mes/ano se citar mês.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação', \" +\n \"'apague despesa fixa aluguel', 'remova assinatura Netflix'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento | despesa_fixa | assinatura; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se configurar *perfil de renda híbrido* (contracheque, recebimento único ou fluxo diário), ex.: \" +\n \"'minha renda agora é fluxo diário com meta de 5000', 'recebo um pix único de 4000 todo dia 10', \" +\n \"'meu salário bruto é 6000 com descontos de 800 dia 5': action SET_INCOME_PROFILE; \" +\n \"tipoPerfil = CONTRACHEQUE | RECEBIMENTO_UNICO | FLUXO_DIARIO; \" +\n \"CONTRACHEQUE: salarioBruto, descontosHolerite (total) ou descontosFixos em updates, diaRecebimento; \" +\n \"RECEBIMENTO_UNICO: valorLiquidoFixo, diaRecebimentoFixo; FLUXO_DIARIO: metaFaturamentoMensal (opcional). \" +\n \"Prefira SET_INCOME_PROFILE quando a frase citar explicitamente o *tipo* de trabalhador/perfil.\\n\" +\n \"- Se confirmar que provisão fiscal caiu na conta (ex.: 'confirma o 13', 'primeira parcela do 13 caiu', \" +\n \"'restituição do IR entrou na conta', 'efetiva o décimo terceiro'): action CONFIRM_FISCAL_PROVISION; \" +\n \"description = termo citado (13, restituição IR, etc.); accountName/bank se citar conta específica.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Listar assinaturas cadastradas/monitoradas (ex.: 'quais minhas assinaturas?', 'lista assinaturas'): action LIST_SUBSCRIPTIONS.\\n\" +\n \"- Cadastrar assinatura recorrente manualmente (ex.: 'assinei o plano do Spotify por 24,90 por mês', \" +\n \"'cadastra a assinatura da Netflix de 55,90 todo dia 15'): action CREATE_SUBSCRIPTION; description = nome; amount = valor mensal; dueDay = dia de cobrança (1-31).\\n\" +\n \"- Cadastrar despesa fixa/obrigação mensal (ex.: 'cadastra a despesa fixa de aluguel valor 1200 vencimento todo dia 10', \" +\n \"'salve essa despesa fixa de 250 para internet dia 10'): action CREATE_FIXED_EXPENSE; description = nome; amount = valor; dueDay = dia de vencimento.\\n\" +\n \"- Ativar ou desativar assinatura cadastrada (ex.: 'desative a assinatura da Netflix', 'pause a academia', 'reative o Spotify'): \" +\n \"action TOGGLE_SUBSCRIPTION; description ou searchPhrase = nome da assinatura; subscriptionActive=false para desativar/pausar, true para ativar.\\n\" +\n \"- Tutorial interativo opt-in (guia de uso do sistema): action START_TUTORIAL para frases como \" +\n \"'tutorial', 'como usar', 'me ensina a usar o sistema', 'como funciona isso aqui', 'ajuda', 'help', 'nao sei usar'.\\n\" +\n \"- START_TUTORIAL também quando o utilizador perguntar sobre uma aba/tela específica — preencha reportMonth com o capítulo (1-5) e entregue o capítulo direto, sem menu:\\n\" +\n \" 'como uso a aba de contas?' → reportMonth=1 · 'me explica a tela de renda' → reportMonth=4 · \" +\n \"'o que e a aba de metas?' → reportMonth=5 · 'nao entendo a aba de cartoes' → reportMonth=3 · \" +\n \"'como funciona a aba de transacoes?' → reportMonth=2.\\n\" +\n \"- Encerrar tutorial ativo: action STOP_TUTORIAL para 'sair', 'parar', 'desligar', 'cancelar', 'encerrar tutorial', 'chega', 'voltar ao normal'.\\n\" +\n \"- Navegar capítulo do tutorial (1 a 5) quando o utilizador está no guia: action TUTORIAL_STEP; preencher reportMonth com o número do capítulo (1-5) se útil.\\n\" +\n \"REGRAS DE TOLERÂNCIA A LINGUAGEM:\\n\" +\n \"Ao interpretar a mensagem do usuário, aplique normalizações ANTES de classificar a intenção.\\n\" +\n \"ABREVIAÇÕES FINANCEIRAS: nu/nub/nuba/nubank→Nubank; ita/itau/itá→Itaú; bb/bradesco→Bradesco; inter→Banco Inter; \" +\n \"xp/xpi→XP; cef/caixa/cx→Caixa; sntdr/santander→Santander.\\n\" +\n \"ESTABELECIMENTOS: mrcdo/mercd/mkt/feira/sacolão→Mercado/Alimentação; ubr/uber/99→Transporte; \" +\n \"ifood/ifd/rappi→Alimentação/Delivery; farm/farmci/drog→Farmácia; posto/gasolina/combust→Combustível; \" +\n \"academia/acad/gym→Academia; netf/netflix/spotify/prime→Assinaturas/Lazer.\\n\" +\n \"INFORMAIS: tô no vermelho/zerado/liso→saldo crítico; sobrou/sobra→consulta saldo; fechei/como tá o mês→relatório; \" +\n \"queimei/estourei→orçamento excedido; guardei/separei→poupança/meta; parcelei/em x vezes→parcelado.\\n\" +\n \"REGRAS: (1) corrija erros óbvios pelo contexto; (2) abreviação dupla→entidade_ambigua+opcoes_entidade; \" +\n \"(3) não invente entidade→entidade_desconhecida; (4) preencha nome_normalizado com o nome corrigido.\\n\" +\n \"Intenção GREETING: ative quando a mensagem for APENAS uma saudação informal brasileira \" +\n \"(oi, olá, iae, iai, e aí, salve, opa, fala, beleza, tudo bem, bom dia, boa tarde, boa noite), sem pedido financeiro.\\n\" +\n \"REGRA IMPORTANTE: se além da saudação houver um comando financeiro na mesma frase \" +\n \"(ex.: 'oi jarvis, quanto tenho na conta?'), classifique pela INTENÇÃO FINANCEIRA principal \" +\n \"(LIST_ACCOUNTS, CREATE_EXPENSE, etc.), NÃO como GREETING.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n /** Visão computacional para boleto bancário ou QR Code Pix (agendamento de pagamento). */\n public JsonNode analisarImagemBoletoPix(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para leitura de boleto/Pix\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você extrai dados de boleto bancário brasileiro ou QR Code Pix para agendamento de pagamento. \"\n + \"Retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise a imagem e extraia: \"\n + \"tipo (BOLETO|PIX|CUPOM_FISCAL|OUTRO), beneficiario (string), valor (número decimal), \"\n + \"dataVencimento (yyyy-MM-dd), linhaDigitavel ou codigoBarrasOuPix (se visível), \"\n + \"confianca (0 a 1), erro (string opcional). \"\n + \"Use tipo CUPOM_FISCAL se for nota fiscal de compra, não boleto. \"\n + \"Se não for possível ler com segurança, preencha erro e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId, systemPrompt, userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, dataUrl);\n },\n \"Falha leitura boleto/Pix em todos provedores: \"\n );\n log.info(\"[VISION-LOG] Boleto/Pix userId={} tipo={} confianca={}\",\n userId, out.path(\"tipo\").asText(\"\"), out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Narra o conselho financeiro — a IA só redige; todos os números já foram calculados em Java.\n */\n public String narrarConselho(Long userId, ResultadoConselho resultado) {\n if (resultado == null) {\n return \"Chefe, não consegui montar o cenário agora. Tenta de novo com valor e parcelas, se houver.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n String vereditoTxt = switch (resultado.getVeredito() != null ? resultado.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"RISCO_ALTO\";\n case SEGURO -> \"SEGURO\";\n case ATENCAO -> \"ATENCAO\";\n };\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Veredito: \").append(vereditoTxt).append(\"\\n\");\n dados.append(\"- Item: \").append(resultado.getDescricaoItem() != null ? resultado.getDescricaoItem() : \"item\").append(\"\\n\");\n if (resultado.getCustoTotal() != null) {\n dados.append(\"- Custo total: \").append(brl.format(resultado.getCustoTotal())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensal() != null && resultado.getTaxaJurosAnual() != null) {\n dados.append(\"- Taxa de juros: \").append(resultado.getTaxaJurosMensal()).append(\"% ao mês / \")\n .append(resultado.getTaxaJurosAnual()).append(\"% ao ano\\n\");\n }\n if (resultado.getPercentualRendaComprometida() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPercentualRendaComprometida()).append(\"%\\n\");\n }\n if (resultado.getSaldoAposCompra() != null) {\n dados.append(\"- Saldo após a compra: \").append(brl.format(resultado.getSaldoAposCompra())).append(\"\\n\");\n }\n if (resultado.getReservaMesesApos() != null) {\n dados.append(\"- Reserva após a compra: \").append(resultado.getReservaMesesApos()).append(\" meses\\n\");\n }\n if (resultado.getMesesReservaAtual() != null) {\n dados.append(\"- Reserva atual (Escudo): \").append(resultado.getMesesReservaAtual()).append(\" meses\\n\");\n }\n if (resultado.getComparacaoMercado() != null) {\n dados.append(\"- Comparação com o mercado: \").append(resultado.getComparacaoMercado());\n if (resultado.getTaxaMercadoReferencia() != null) {\n dados.append(\" (referência \").append(resultado.getTaxaMercadoReferencia()).append(\"% a.a.)\");\n }\n dados.append(\"\\n\");\n }\n if (resultado.isAvisoSemValorTomado()) {\n dados.append(\"- Aviso: valor tomado/à vista não informado — juros não calculados\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S., estrategista financeiro pessoal do chefe. Os cálculos JÁ FORAM FEITOS.\\n\"\n + \"Sua tarefa é APENAS transformar os dados abaixo em uma resposta clara, direta e humana.\\n\"\n + \"NÃO recalcule nada. NÃO invente números. Use exatamente os valores fornecidos.\\n\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Comece com o selo do veredito: 🔴 *[RISCO ALTO]* | 🟡 *[ATENÇÃO]* | 🟢 *[PODE IR]*\\n\"\n + \"2. Explique o PORQUÊ em linguagem simples, sem jargão técnico.\\n\"\n + \"3. Se houver juros, mostre o impacto concreto no bolso.\\n\"\n + \"4. Para parcelas, diga quantos % da renda ficam comprometidos.\\n\"\n + \"5. Em decisões grandes, feche lembrando que a decisão é do chefe.\\n\"\n + \"6. Tom: direto, honesto, protetor do bolso. Chame de chefe.\\n\"\n + \"7. Máximo 2 emojis além do selo.\\n\"\n + \"8. Orientação baseada nos dados cadastrados — não é garantia nem consultoria profissional.\\n\";\n String userPrompt = \"DADOS CALCULADOS:\\n\" + dados;\n String fallback = montarFallbackDeterministico(resultado, brl);\n return gerarTexto(userId, system, userPrompt, fallback);\n }\n\n /**\n * Narra o registro de empréstimo consignado — números já calculados em Java.\n */\n public String narrarRegistroEmprestimo(Long userId, ResultadoRegistroEmprestimo resultado) {\n if (resultado == null) {\n return \"Chefe, não consegui concluir o registro do empréstimo agora.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Valor creditado: \").append(brl.format(resultado.getValorTomado())).append(\"\\n\");\n if (resultado.getContaNome() != null) {\n dados.append(\"- Conta: \").append(resultado.getContaNome()).append(\"\\n\");\n }\n if (resultado.getNovoSaldoConta() != null) {\n dados.append(\"- Novo saldo da conta: \").append(brl.format(resultado.getNovoSaldoConta())).append(\"\\n\");\n }\n dados.append(\"- Parcela: \").append(brl.format(resultado.getValorParcela()));\n if (resultado.isParcelaEstimada()) {\n dados.append(\" (estimada pela taxa média de mercado)\");\n }\n dados.append(\"\\n\");\n dados.append(\"- Nº de parcelas: \").append(resultado.getQuantidadeParcelas());\n if (resultado.getDataPrimeiraParcela() != null && resultado.getDataUltimaParcela() != null) {\n dados.append(\" (\").append(resultado.getDataPrimeiraParcela())\n .append(\" até \").append(resultado.getDataUltimaParcela()).append(\")\\n\");\n } else {\n dados.append(\"\\n\");\n }\n if (resultado.getTotalAPagar() != null) {\n dados.append(\"- Total a pagar: \").append(brl.format(resultado.getTotalAPagar())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensalPct() != null && resultado.getTaxaJurosAnualPct() != null) {\n dados.append(\"- Taxa efetiva: \").append(resultado.getTaxaJurosMensalPct())\n .append(\"% a.m. / \").append(resultado.getTaxaJurosAnualPct()).append(\"% a.a.\\n\");\n }\n if (resultado.getPctRendaComprometidaDepois() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPctRendaComprometidaDepois()).append(\"%\\n\");\n }\n if (resultado.getRendaLivreAntes() != null && resultado.getRendaLivreDepois() != null) {\n dados.append(\"- Renda livre: era \").append(brl.format(resultado.getRendaLivreAntes()))\n .append(\", ficou \").append(brl.format(resultado.getRendaLivreDepois())).append(\"\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S. Os cálculos JÁ FORAM FEITOS — NÃO recalcule.\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Confirme o crédito e o novo saldo primeiro.\\n\"\n + \"2. Mostre o custo real: quanto recebe vs quanto devolve e juros.\\n\"\n + \"3. Diga quanto da renda fica comprometido e por quantos meses.\\n\"\n + \"4. Se parcela estimada, avise para informar valor exato do contrato.\\n\"\n + \"5. Linguagem simples. Chame de chefe. Máximo 2 emojis.\\n\"\n + \"6. Feche lembrando que dá pra acompanhar na aba Transações.\\n\";\n String fallback = montarFallbackRegistroEmprestimo(resultado, brl);\n return gerarTexto(userId, system, \"DADOS CALCULADOS (não recalcule, apenas redija):\\n\" + dados, fallback);\n }\n\n private static String montarFallbackRegistroEmprestimo(ResultadoRegistroEmprestimo r, NumberFormat brl) {\n StringBuilder sb = new StringBuilder();\n sb.append(\"Feito, chefe! Creditei *\").append(brl.format(r.getValorTomado())).append(\"*\");\n if (r.getContaNome() != null) {\n sb.append(\" na *\").append(r.getContaNome()).append(\"*\");\n }\n if (r.getNovoSaldoConta() != null) {\n sb.append(\" — saldo agora *\").append(brl.format(r.getNovoSaldoConta())).append(\"*\");\n }\n sb.append(\".\\n\");\n sb.append(\"São *\").append(r.getQuantidadeParcelas()).append(\"x de \")\n .append(brl.format(r.getValorParcela()));\n if (r.isParcelaEstimada()) {\n sb.append(\" _(estimada — me passe o valor exato do contrato pra ajustar)_\");\n }\n sb.append(\".\\n\");\n if (r.getJurosTotais() != null && r.getTotalAPagar() != null) {\n sb.append(\"Você recebe \").append(brl.format(r.getValorTomado()))\n .append(\", mas devolve \").append(brl.format(r.getTotalAPagar()))\n .append(\" — \").append(brl.format(r.getJurosTotais())).append(\" de juros.\\n\");\n }\n if (r.getPctRendaComprometidaDepois() != null) {\n sb.append(\"Comprometimento da renda: *\").append(r.getPctRendaComprometidaDepois()).append(\"%*.\\n\");\n }\n sb.append(\"Acompanhe as parcelas em *Transações*.\");\n return sb.toString();\n }\n\n private static String montarFallbackDeterministico(ResultadoConselho r, NumberFormat brl) {\n String selo = switch (r.getVeredito() != null ? r.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"🔴 *[RISCO ALTO]*\";\n case SEGURO -> \"🟢 *[PODE IR]*\";\n case ATENCAO -> \"🟡 *[ATENÇÃO]*\";\n };\n StringBuilder sb = new StringBuilder(selo).append(\" \");\n sb.append(\"Sobre \").append(r.getDescricaoItem() != null ? r.getDescricaoItem() : \"essa operação\").append(\": \");\n if (r.getCustoTotal() != null) {\n sb.append(\"custo total \").append(brl.format(r.getCustoTotal())).append(\". \");\n }\n if (r.getJurosTotais() != null && r.getJurosTotais().compareTo(BigDecimal.ZERO) > 0) {\n sb.append(\"Juros: \").append(brl.format(r.getJurosTotais())).append(\". \");\n }\n if (r.getPercentualRendaComprometida() != null) {\n sb.append(\"Compromete \").append(r.getPercentualRendaComprometida()).append(\"% da sua renda. \");\n }\n if (r.getSaldoAposCompra() != null) {\n sb.append(\"Saldo após compra: \").append(brl.format(r.getSaldoAposCompra())).append(\". \");\n }\n sb.append(\"A palavra final é sua, chefe.\");\n return sb.toString();\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "22ef0a23c9687f1c8ce3e6c1c63780a6255d031c": {"t": 1785752803.8498592, "n": 73730, "h": "81462f297fad7f28", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.ResultadoConselho;\nimport com.consumoesperto.model.ResultadoRegistroEmprestimo;\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.model.Veredito;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.text.NumberFormat;\nimport java.util.ArrayList;\nimport java.util.Locale;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private JarvisContextoFinanceiroService jarvisContextoFinanceiroService;\n\n private RestTemplate restTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null || jarvisContextoFinanceiroService == null) {\n return \"\";\n }\n try {\n return jarvisContextoFinanceiroService.montarBlocoContexto(userId);\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|CREATE_FIXED_EXPENSE|CREATE_SUBSCRIPTION|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_FINANCIAL_ADVICE|RECORD_CONSIGNMENT_LOAN|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|LIST_ACCOUNTS|TRANSFER_BETWEEN_ACCOUNTS|LIST_TRANSACTIONS|LIST_CATEGORIES|LIST_METAS|GET_REPORT_SUMMARY|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|SET_INCOME_PROFILE|CONFIRM_FISCAL_PROVISION|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|LIST_SUBSCRIPTIONS|TOGGLE_SUBSCRIPTION|START_TUTORIAL|STOP_TUTORIAL|TUTORIAL_STEP|GREETING|UNKNOWN), \" +\n \"valorTomado (valor recebido do empréstimo consignado), nomeConta (apelido da conta que recebeu), \" +\n \"contaOrigem (apelido/nome da conta de origem na transferência), contaDestino (apelido/nome da conta de destino na transferência), \" +\n \"mes (1-12, opcional — consultas de extrato/orçamento), ano (ex.: 2026, opcional), tipo (DESPESA|RECEITA, opcional — filtro de extrato), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento|despesa_fixa|assinatura), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"subscriptionActive (true para ativar/reativar assinatura cadastrada; false para desativar/pausar), \" +\n \"tipoPerfil (CONTRACHEQUE|RECEBIMENTO_UNICO|FLUXO_DIARIO — perfil híbrido de renda), \" +\n \"salarioBruto, descontosHolerite (total ou use descontosFixos em updates), diaRecebimento (1-31, CONTRACHEQUE), \" +\n \"valorLiquidoFixo, diaRecebimentoFixo (RECEBIMENTO_UNICO), metaFaturamentoMensal (FLUXO_DIARIO), \" +\n \"tipoOperacao (COMPRA_AVISTA|COMPRA_PARCELADA|EMPRESTIMO|CONSIGNADO|FINANCIAMENTO — conselho de compra/empréstimo), \" +\n \"valorTotal (valor do bem ou valor tomado no empréstimo), valorParcela (valor de cada parcela), \" +\n \"quantidadeParcelas (número de parcelas), descricaoItem (ex.: moto, tênis, consignado), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"nome_normalizado (nome corrigido da entidade identificada), entidade_ambigua (true se abreviação dupla), \" +\n \"entidade_desconhecida (true se não reconhecer), opcoes_entidade (array de nomes quando ambíguo), \" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount; categoryName quando o estabelecimento \" +\n \"implicar categoria (ex.: ubr/uber→Transporte, mrcdo/mercado→Alimentação); nome_normalizado nas entidades.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Despesa com beneficiário PIX no extrato do cartão (ex.: 'pix Maria Silva 3340 no cartão Itaú em 2x'): paymentMethod=CARTAO, \" +\n \"bank ou cardName = cartão citado (Itaú/Nubank/Azul), description = nome do beneficiário; NÃO use paymentMethod=CONTA nem cardName=beneficiário. \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor, dia_vencimento (1-31).\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se pedir conselho sobre compra, empréstimo, consignado ou financiamento (ex.: 'vale a pena comprar moto de 15k parcelada?', \" +\n \"'consignado de 10 mil em 24x de 550 o que acha?', 'posso comprar um tênis de 800 hoje?', 'devo pegar esse empréstimo?'): \" +\n \"action GET_FINANCIAL_ADVICE; preencha tipoOperacao, valorTotal, valorParcela, quantidadeParcelas, descricaoItem. \" +\n \"Normalização numérica: '15k'/'15 mil'→15000; '10.000'→10000; '24x de 550'→quantidadeParcelas=24, valorParcela=550; \" +\n \"'550 por mês durante 2 anos'→valorParcela=550, quantidadeParcelas=24. \" +\n \"Se citar parcelas/consignado/financiamento→operação parcelada; 'à vista'/'hoje'/'agora' sem parcelas→COMPRA_AVISTA. \" +\n \"NÃO calcule juros nem veredito — apenas extraia parâmetros.\\n\" +\n \"- Se REGISTRAR empréstimo/consignado JÁ CONTRATADO (passado: 'fiz', 'contratei', 'peguei', 'caiu na conta'), \" +\n \"NÃO use GET_FINANCIAL_ADVICE — use action RECORD_CONSIGNMENT_LOAN; preencha valorTomado (ou valorTotal), \" +\n \"quantidadeParcelas, valorParcela se citado, nomeConta/accountName/bank se citar conta.\\n\" +\n \"- Se perguntar SE VALE A PENA / opinião sobre empréstimo futuro: GET_FINANCIAL_ADVICE.\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se pedir saldos das contas, patrimônio em contas ou quanto tem nas contas (ex.: 'quanto eu tenho nas contas?', \" +\n \"'saldos das contas', 'meu patrimônio', 'lista minhas contas', 'saldo do nu', 'saldo da conta Nubank'): \" +\n \"action LIST_ACCOUNTS; preencher accountName ou bank com o apelido citado; nome_normalizado com o nome corrigido.\\n\" +\n \"- Se pedir transferir/mover/passar valor entre contas bancárias (ex.: 'transfere 100 do Itaú pro Nubank', \" +\n \"'passa 50 reais da conta Inter para a poupança', 'manda 200 da corrente pra carteira'): action TRANSFER_BETWEEN_ACCOUNTS; \" +\n \"preencher amount (valor), contaOrigem e contaDestino com os apelidos citados; NÃO use CREATE_EXPENSE para transferências internas.\\n\" +\n \"- Se pedir extrato, últimos gastos/receitas ou listar transações (ex.: 'quais foram meus últimos gastos?', \" +\n \"'lista os pix que recebi em junho', 'o que gastei na categoria mercado esse mês?'): action LIST_TRANSACTIONS; \" +\n \"preencher mes/ano ou reportMonth/reportYear quando citar período; tipo=DESPESA ou RECEITA se filtrar; categoryName/categoria se citar categoria.\\n\" +\n \"- Se pedir listar categorias cadastradas (ex.: 'quais categorias eu tenho?', 'lista minhas categorias'): action LIST_CATEGORIES.\\n\" +\n \"- Se pedir estado das metas/objetivos (ex.: 'como estão minhas metas?', 'quais são os meus objetivos de economia?'): action LIST_METAS.\\n\" +\n \"- Se pedir resumo de orçamento, limites por categoria ou gasto vs limite (ex.: 'quanto gastei do meu orçamento?', \" +\n \"'resumo de gastos do orçamento', 'meus limites de categoria'): action GET_REPORT_SUMMARY; mes/ano se citar mês.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação', \" +\n \"'apague despesa fixa aluguel', 'remova assinatura Netflix'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento | despesa_fixa | assinatura; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se configurar *perfil de renda híbrido* (contracheque, recebimento único ou fluxo diário), ex.: \" +\n \"'minha renda agora é fluxo diário com meta de 5000', 'recebo um pix único de 4000 todo dia 10', \" +\n \"'meu salário bruto é 6000 com descontos de 800 dia 5': action SET_INCOME_PROFILE; \" +\n \"tipoPerfil = CONTRACHEQUE | RECEBIMENTO_UNICO | FLUXO_DIARIO; \" +\n \"CONTRACHEQUE: salarioBruto, descontosHolerite (total) ou descontosFixos em updates, diaRecebimento; \" +\n \"RECEBIMENTO_UNICO: valorLiquidoFixo, diaRecebimentoFixo; FLUXO_DIARIO: metaFaturamentoMensal (opcional). \" +\n \"Prefira SET_INCOME_PROFILE quando a frase citar explicitamente o *tipo* de trabalhador/perfil.\\n\" +\n \"- Se confirmar que provisão fiscal caiu na conta (ex.: 'confirma o 13', 'primeira parcela do 13 caiu', \" +\n \"'restituição do IR entrou na conta', 'efetiva o décimo terceiro'): action CONFIRM_FISCAL_PROVISION; \" +\n \"description = termo citado (13, restituição IR, etc.); accountName/bank se citar conta específica.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Listar assinaturas cadastradas/monitoradas (ex.: 'quais minhas assinaturas?', 'lista assinaturas'): action LIST_SUBSCRIPTIONS.\\n\" +\n \"- Cadastrar assinatura recorrente manualmente (ex.: 'assinei o plano do Spotify por 24,90 por mês', \" +\n \"'cadastra a assinatura da Netflix de 55,90 todo dia 15'): action CREATE_SUBSCRIPTION; description = nome; amount = valor mensal; dueDay = dia de cobrança (1-31).\\n\" +\n \"- Cadastrar despesa fixa/obrigação mensal (ex.: 'cadastra a despesa fixa de aluguel valor 1200 vencimento todo dia 10', \" +\n \"'salve essa despesa fixa de 250 para internet dia 10'): action CREATE_FIXED_EXPENSE; description = nome; amount = valor; dueDay = dia de vencimento.\\n\" +\n \"- Ativar ou desativar assinatura cadastrada (ex.: 'desative a assinatura da Netflix', 'pause a academia', 'reative o Spotify'): \" +\n \"action TOGGLE_SUBSCRIPTION; description ou searchPhrase = nome da assinatura; subscriptionActive=false para desativar/pausar, true para ativar.\\n\" +\n \"- Tutorial interativo opt-in (guia de uso do sistema): action START_TUTORIAL para frases como \" +\n \"'tutorial', 'como usar', 'me ensina a usar o sistema', 'como funciona isso aqui', 'ajuda', 'help', 'nao sei usar'.\\n\" +\n \"- START_TUTORIAL também quando o utilizador perguntar sobre uma aba/tela específica — preencha reportMonth com o capítulo (1-5) e entregue o capítulo direto, sem menu:\\n\" +\n \" 'como uso a aba de contas?' → reportMonth=1 · 'me explica a tela de renda' → reportMonth=4 · \" +\n \"'o que e a aba de metas?' → reportMonth=5 · 'nao entendo a aba de cartoes' → reportMonth=3 · \" +\n \"'como funciona a aba de transacoes?' → reportMonth=2.\\n\" +\n \"- Encerrar tutorial ativo: action STOP_TUTORIAL para 'sair', 'parar', 'desligar', 'cancelar', 'encerrar tutorial', 'chega', 'voltar ao normal'.\\n\" +\n \"- Navegar capítulo do tutorial (1 a 5) quando o utilizador está no guia: action TUTORIAL_STEP; preencher reportMonth com o número do capítulo (1-5) se útil.\\n\" +\n \"REGRAS DE TOLERÂNCIA A LINGUAGEM:\\n\" +\n \"Ao interpretar a mensagem do usuário, aplique normalizações ANTES de classificar a intenção.\\n\" +\n \"ABREVIAÇÕES FINANCEIRAS: nu/nub/nuba/nubank→Nubank; ita/itau/itá→Itaú; bb/bradesco→Bradesco; inter→Banco Inter; \" +\n \"xp/xpi→XP; cef/caixa/cx→Caixa; sntdr/santander→Santander.\\n\" +\n \"ESTABELECIMENTOS: mrcdo/mercd/mkt/feira/sacolão→Mercado/Alimentação; ubr/uber/99→Transporte; \" +\n \"ifood/ifd/rappi→Alimentação/Delivery; farm/farmci/drog→Farmácia; posto/gasolina/combust→Combustível; \" +\n \"academia/acad/gym→Academia; netf/netflix/spotify/prime→Assinaturas/Lazer.\\n\" +\n \"INFORMAIS: tô no vermelho/zerado/liso→saldo crítico; sobrou/sobra→consulta saldo; fechei/como tá o mês→relatório; \" +\n \"queimei/estourei→orçamento excedido; guardei/separei→poupança/meta; parcelei/em x vezes→parcelado.\\n\" +\n \"REGRAS: (1) corrija erros óbvios pelo contexto; (2) abreviação dupla→entidade_ambigua+opcoes_entidade; \" +\n \"(3) não invente entidade→entidade_desconhecida; (4) preencha nome_normalizado com o nome corrigido.\\n\" +\n \"Intenção GREETING: ative quando a mensagem for APENAS uma saudação informal brasileira \" +\n \"(oi, olá, iae, iai, e aí, salve, opa, fala, beleza, tudo bem, bom dia, boa tarde, boa noite), sem pedido financeiro.\\n\" +\n \"REGRA IMPORTANTE: se além da saudação houver um comando financeiro na mesma frase \" +\n \"(ex.: 'oi jarvis, quanto tenho na conta?'), classifique pela INTENÇÃO FINANCEIRA principal \" +\n \"(LIST_ACCOUNTS, CREATE_EXPENSE, etc.), NÃO como GREETING.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n /** Visão computacional para boleto bancário ou QR Code Pix (agendamento de pagamento). */\n public JsonNode analisarImagemBoletoPix(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para leitura de boleto/Pix\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você extrai dados de boleto bancário brasileiro ou QR Code Pix para agendamento de pagamento. \"\n + \"Retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise a imagem e extraia: \"\n + \"tipo (BOLETO|PIX|CUPOM_FISCAL|OUTRO), beneficiario (string), valor (número decimal), \"\n + \"dataVencimento (yyyy-MM-dd), linhaDigitavel ou codigoBarrasOuPix (se visível), \"\n + \"confianca (0 a 1), erro (string opcional). \"\n + \"Use tipo CUPOM_FISCAL se for nota fiscal de compra, não boleto. \"\n + \"Se não for possível ler com segurança, preencha erro e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId, systemPrompt, userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, dataUrl);\n },\n \"Falha leitura boleto/Pix em todos provedores: \"\n );\n log.info(\"[VISION-LOG] Boleto/Pix userId={} tipo={} confianca={}\",\n userId, out.path(\"tipo\").asText(\"\"), out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Narra o conselho financeiro — a IA só redige; todos os números já foram calculados em Java.\n */\n public String narrarConselho(Long userId, ResultadoConselho resultado) {\n if (resultado == null) {\n return \"Chefe, não consegui montar o cenário agora. Tenta de novo com valor e parcelas, se houver.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n String vereditoTxt = switch (resultado.getVeredito() != null ? resultado.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"RISCO_ALTO\";\n case SEGURO -> \"SEGURO\";\n case ATENCAO -> \"ATENCAO\";\n };\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Veredito: \").append(vereditoTxt).append(\"\\n\");\n dados.append(\"- Item: \").append(resultado.getDescricaoItem() != null ? resultado.getDescricaoItem() : \"item\").append(\"\\n\");\n if (resultado.getCustoTotal() != null) {\n dados.append(\"- Custo total: \").append(brl.format(resultado.getCustoTotal())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensal() != null && resultado.getTaxaJurosAnual() != null) {\n dados.append(\"- Taxa de juros: \").append(resultado.getTaxaJurosMensal()).append(\"% ao mês / \")\n .append(resultado.getTaxaJurosAnual()).append(\"% ao ano\\n\");\n }\n if (resultado.getPercentualRendaComprometida() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPercentualRendaComprometida()).append(\"%\\n\");\n }\n if (resultado.getSaldoAposCompra() != null) {\n dados.append(\"- Saldo após a compra: \").append(brl.format(resultado.getSaldoAposCompra())).append(\"\\n\");\n }\n if (resultado.getReservaMesesApos() != null) {\n dados.append(\"- Reserva após a compra: \").append(resultado.getReservaMesesApos()).append(\" meses\\n\");\n }\n if (resultado.getMesesReservaAtual() != null) {\n dados.append(\"- Reserva atual (Escudo): \").append(resultado.getMesesReservaAtual()).append(\" meses\\n\");\n }\n if (resultado.getComparacaoMercado() != null) {\n dados.append(\"- Comparação com o mercado: \").append(resultado.getComparacaoMercado());\n if (resultado.getTaxaMercadoReferencia() != null) {\n dados.append(\" (referência \").append(resultado.getTaxaMercadoReferencia()).append(\"% a.a.)\");\n }\n dados.append(\"\\n\");\n }\n if (resultado.isAvisoSemValorTomado()) {\n dados.append(\"- Aviso: valor tomado/à vista não informado — juros não calculados\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S., estrategista financeiro pessoal do chefe. Os cálculos JÁ FORAM FEITOS.\\n\"\n + \"Sua tarefa é APENAS transformar os dados abaixo em uma resposta clara, direta e humana.\\n\"\n + \"NÃO recalcule nada. NÃO invente números. Use exatamente os valores fornecidos.\\n\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Comece com o selo do veredito: 🔴 *[RISCO ALTO]* | 🟡 *[ATENÇÃO]* | 🟢 *[PODE IR]*\\n\"\n + \"2. Explique o PORQUÊ em linguagem simples, sem jargão técnico.\\n\"\n + \"3. Se houver juros, mostre o impacto concreto no bolso.\\n\"\n + \"4. Para parcelas, diga quantos % da renda ficam comprometidos.\\n\"\n + \"5. Em decisões grandes, feche lembrando que a decisão é do chefe.\\n\"\n + \"6. Tom: direto, honesto, protetor do bolso. Chame de chefe.\\n\"\n + \"7. Máximo 2 emojis além do selo.\\n\"\n + \"8. Orientação baseada nos dados cadastrados — não é garantia nem consultoria profissional.\\n\";\n String userPrompt = \"DADOS CALCULADOS:\\n\" + dados;\n String fallback = montarFallbackDeterministico(resultado, brl);\n return gerarTexto(userId, system, userPrompt, fallback);\n }\n\n /**\n * Narra o registro de empréstimo consignado — números já calculados em Java.\n */\n public String narrarRegistroEmprestimo(Long userId, ResultadoRegistroEmprestimo resultado) {\n if (resultado == null) {\n return \"Chefe, não consegui concluir o registro do empréstimo agora.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Valor creditado: \").append(brl.format(resultado.getValorTomado())).append(\"\\n\");\n if (resultado.getContaNome() != null) {\n dados.append(\"- Conta: \").append(resultado.getContaNome()).append(\"\\n\");\n }\n if (resultado.getNovoSaldoConta() != null) {\n dados.append(\"- Novo saldo da conta: \").append(brl.format(resultado.getNovoSaldoConta())).append(\"\\n\");\n }\n dados.append(\"- Parcela: \").append(brl.format(resultado.getValorParcela()));\n if (resultado.isParcelaEstimada()) {\n dados.append(\" (estimada pela taxa média de mercado)\");\n }\n dados.append(\"\\n\");\n dados.append(\"- Nº de parcelas: \").append(resultado.getQuantidadeParcelas());\n if (resultado.getDataPrimeiraParcela() != null && resultado.getDataUltimaParcela() != null) {\n dados.append(\" (\").append(resultado.getDataPrimeiraParcela())\n .append(\" até \").append(resultado.getDataUltimaParcela()).append(\")\\n\");\n } else {\n dados.append(\"\\n\");\n }\n if (resultado.getTotalAPagar() != null) {\n dados.append(\"- Total a pagar: \").append(brl.format(resultado.getTotalAPagar())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensalPct() != null && resultado.getTaxaJurosAnualPct() != null) {\n dados.append(\"- Taxa efetiva: \").append(resultado.getTaxaJurosMensalPct())\n .append(\"% a.m. / \").append(resultado.getTaxaJurosAnualPct()).append(\"% a.a.\\n\");\n }\n if (resultado.getPctRendaComprometidaDepois() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPctRendaComprometidaDepois()).append(\"%\\n\");\n }\n if (resultado.getRendaLivreAntes() != null && resultado.getRendaLivreDepois() != null) {\n dados.append(\"- Renda livre: era \").append(brl.format(resultado.getRendaLivreAntes()))\n .append(\", ficou \").append(brl.format(resultado.getRendaLivreDepois())).append(\"\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S. Os cálculos JÁ FORAM FEITOS — NÃO recalcule.\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Confirme o crédito e o novo saldo primeiro.\\n\"\n + \"2. Mostre o custo real: quanto recebe vs quanto devolve e juros.\\n\"\n + \"3. Diga quanto da renda fica comprometido e por quantos meses.\\n\"\n + \"4. Se parcela estimada, avise para informar valor exato do contrato.\\n\"\n + \"5. Linguagem simples. Chame de chefe. Máximo 2 emojis.\\n\"\n + \"6. Feche lembrando que dá pra acompanhar na aba Transações.\\n\";\n String fallback = montarFallbackRegistroEmprestimo(resultado, brl);\n return gerarTexto(userId, system, \"DADOS CALCULADOS (não recalcule, apenas redija):\\n\" + dados, fallback);\n }\n\n private static String montarFallbackRegistroEmprestimo(ResultadoRegistroEmprestimo r, NumberFormat brl) {\n StringBuilder sb = new StringBuilder();\n sb.append(\"Feito, chefe! Creditei *\").append(brl.format(r.getValorTomado())).append(\"*\");\n if (r.getContaNome() != null) {\n sb.append(\" na *\").append(r.getContaNome()).append(\"*\");\n }\n if (r.getNovoSaldoConta() != null) {\n sb.append(\" — saldo agora *\").append(brl.format(r.getNovoSaldoConta())).append(\"*\");\n }\n sb.append(\".\\n\");\n sb.append(\"São *\").append(r.getQuantidadeParcelas()).append(\"x de \")\n .append(brl.format(r.getValorParcela()));\n if (r.isParcelaEstimada()) {\n sb.append(\" _(estimada — me passe o valor exato do contrato pra ajustar)_\");\n }\n sb.append(\".\\n\");\n if (r.getJurosTotais() != null && r.getTotalAPagar() != null) {\n sb.append(\"Você recebe \").append(brl.format(r.getValorTomado()))\n .append(\", mas devolve \").append(brl.format(r.getTotalAPagar()))\n .append(\" — \").append(brl.format(r.getJurosTotais())).append(\" de juros.\\n\");\n }\n if (r.getPctRendaComprometidaDepois() != null) {\n sb.append(\"Comprometimento da renda: *\").append(r.getPctRendaComprometidaDepois()).append(\"%*.\\n\");\n }\n sb.append(\"Acompanhe as parcelas em *Transações*.\");\n return sb.toString();\n }\n\n private static String montarFallbackDeterministico(ResultadoConselho r, NumberFormat brl) {\n String selo = switch (r.getVeredito() != null ? r.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"🔴 *[RISCO ALTO]*\";\n case SEGURO -> \"🟢 *[PODE IR]*\";\n case ATENCAO -> \"🟡 *[ATENÇÃO]*\";\n };\n StringBuilder sb = new StringBuilder(selo).append(\" \");\n sb.append(\"Sobre \").append(r.getDescricaoItem() != null ? r.getDescricaoItem() : \"essa operação\").append(\": \");\n if (r.getCustoTotal() != null) {\n sb.append(\"custo total \").append(brl.format(r.getCustoTotal())).append(\". \");\n }\n if (r.getJurosTotais() != null && r.getJurosTotais().compareTo(BigDecimal.ZERO) > 0) {\n sb.append(\"Juros: \").append(brl.format(r.getJurosTotais())).append(\". \");\n }\n if (r.getPercentualRendaComprometida() != null) {\n sb.append(\"Compromete \").append(r.getPercentualRendaComprometida()).append(\"% da sua renda. \");\n }\n if (r.getSaldoAposCompra() != null) {\n sb.append(\"Saldo após compra: \").append(brl.format(r.getSaldoAposCompra())).append(\". \");\n }\n sb.append(\"A palavra final é sua, chefe.\");\n return sb.toString();\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "37a12a0ecfdaf1d03b0394359c43f18d79247205": {"t": 1785752806.6388688, "n": 73428, "h": "1ec4e774a5669155", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.ResultadoConselho;\nimport com.consumoesperto.model.ResultadoRegistroEmprestimo;\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.model.Veredito;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.text.NumberFormat;\nimport java.util.ArrayList;\nimport java.util.Locale;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private JarvisContextoFinanceiroService jarvisContextoFinanceiroService;\n\n private RestTemplate restTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null || jarvisContextoFinanceiroService == null) {\n return \"\";\n }\n try {\n return jarvisContextoFinanceiroService.montarBlocoContexto(userId);\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|CREATE_FIXED_EXPENSE|CREATE_SUBSCRIPTION|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_FINANCIAL_ADVICE|RECORD_CONSIGNMENT_LOAN|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|LIST_ACCOUNTS|TRANSFER_BETWEEN_ACCOUNTS|LIST_TRANSACTIONS|LIST_CATEGORIES|LIST_METAS|GET_REPORT_SUMMARY|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|SET_INCOME_PROFILE|CONFIRM_FISCAL_PROVISION|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|LIST_SUBSCRIPTIONS|TOGGLE_SUBSCRIPTION|START_TUTORIAL|STOP_TUTORIAL|TUTORIAL_STEP|GREETING|UNKNOWN), \" +\n \"valorTomado (valor recebido do empréstimo consignado), nomeConta (apelido da conta que recebeu), \" +\n \"contaOrigem (apelido/nome da conta de origem na transferência), contaDestino (apelido/nome da conta de destino na transferência), \" +\n \"mes (1-12, opcional — consultas de extrato/orçamento), ano (ex.: 2026, opcional), tipo (DESPESA|RECEITA, opcional — filtro de extrato), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento|despesa_fixa|assinatura), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"subscriptionActive (true para ativar/reativar assinatura cadastrada; false para desativar/pausar), \" +\n \"tipoPerfil (CONTRACHEQUE|RECEBIMENTO_UNICO|FLUXO_DIARIO — perfil híbrido de renda), \" +\n \"salarioBruto, descontosHolerite (total ou use descontosFixos em updates), diaRecebimento (1-31, CONTRACHEQUE), \" +\n \"valorLiquidoFixo, diaRecebimentoFixo (RECEBIMENTO_UNICO), metaFaturamentoMensal (FLUXO_DIARIO), \" +\n \"tipoOperacao (COMPRA_AVISTA|COMPRA_PARCELADA|EMPRESTIMO|CONSIGNADO|FINANCIAMENTO — conselho de compra/empréstimo), \" +\n \"valorTotal (valor do bem ou valor tomado no empréstimo), valorParcela (valor de cada parcela), \" +\n \"quantidadeParcelas (número de parcelas), descricaoItem (ex.: moto, tênis, consignado), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"nome_normalizado (nome corrigido da entidade identificada), entidade_ambigua (true se abreviação dupla), \" +\n \"entidade_desconhecida (true se não reconhecer), opcoes_entidade (array de nomes quando ambíguo), \" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount; categoryName quando o estabelecimento \" +\n \"implicar categoria (ex.: ubr/uber→Transporte, mrcdo/mercado→Alimentação); nome_normalizado nas entidades.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor, dia_vencimento (1-31).\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se pedir conselho sobre compra, empréstimo, consignado ou financiamento (ex.: 'vale a pena comprar moto de 15k parcelada?', \" +\n \"'consignado de 10 mil em 24x de 550 o que acha?', 'posso comprar um tênis de 800 hoje?', 'devo pegar esse empréstimo?'): \" +\n \"action GET_FINANCIAL_ADVICE; preencha tipoOperacao, valorTotal, valorParcela, quantidadeParcelas, descricaoItem. \" +\n \"Normalização numérica: '15k'/'15 mil'→15000; '10.000'→10000; '24x de 550'→quantidadeParcelas=24, valorParcela=550; \" +\n \"'550 por mês durante 2 anos'→valorParcela=550, quantidadeParcelas=24. \" +\n \"Se citar parcelas/consignado/financiamento→operação parcelada; 'à vista'/'hoje'/'agora' sem parcelas→COMPRA_AVISTA. \" +\n \"NÃO calcule juros nem veredito — apenas extraia parâmetros.\\n\" +\n \"- Se REGISTRAR empréstimo/consignado JÁ CONTRATADO (passado: 'fiz', 'contratei', 'peguei', 'caiu na conta'), \" +\n \"NÃO use GET_FINANCIAL_ADVICE — use action RECORD_CONSIGNMENT_LOAN; preencha valorTomado (ou valorTotal), \" +\n \"quantidadeParcelas, valorParcela se citado, nomeConta/accountName/bank se citar conta.\\n\" +\n \"- Se perguntar SE VALE A PENA / opinião sobre empréstimo futuro: GET_FINANCIAL_ADVICE.\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se pedir saldos das contas, patrimônio em contas ou quanto tem nas contas (ex.: 'quanto eu tenho nas contas?', \" +\n \"'saldos das contas', 'meu patrimônio', 'lista minhas contas', 'saldo do nu', 'saldo da conta Nubank'): \" +\n \"action LIST_ACCOUNTS; preencher accountName ou bank com o apelido citado; nome_normalizado com o nome corrigido.\\n\" +\n \"- Se pedir transferir/mover/passar valor entre contas bancárias (ex.: 'transfere 100 do Itaú pro Nubank', \" +\n \"'passa 50 reais da conta Inter para a poupança', 'manda 200 da corrente pra carteira'): action TRANSFER_BETWEEN_ACCOUNTS; \" +\n \"preencher amount (valor), contaOrigem e contaDestino com os apelidos citados; NÃO use CREATE_EXPENSE para transferências internas.\\n\" +\n \"- Se pedir extrato, últimos gastos/receitas ou listar transações (ex.: 'quais foram meus últimos gastos?', \" +\n \"'lista os pix que recebi em junho', 'o que gastei na categoria mercado esse mês?'): action LIST_TRANSACTIONS; \" +\n \"preencher mes/ano ou reportMonth/reportYear quando citar período; tipo=DESPESA ou RECEITA se filtrar; categoryName/categoria se citar categoria.\\n\" +\n \"- Se pedir listar categorias cadastradas (ex.: 'quais categorias eu tenho?', 'lista minhas categorias'): action LIST_CATEGORIES.\\n\" +\n \"- Se pedir estado das metas/objetivos (ex.: 'como estão minhas metas?', 'quais são os meus objetivos de economia?'): action LIST_METAS.\\n\" +\n \"- Se pedir resumo de orçamento, limites por categoria ou gasto vs limite (ex.: 'quanto gastei do meu orçamento?', \" +\n \"'resumo de gastos do orçamento', 'meus limites de categoria'): action GET_REPORT_SUMMARY; mes/ano se citar mês.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação', \" +\n \"'apague despesa fixa aluguel', 'remova assinatura Netflix'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento | despesa_fixa | assinatura; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se configurar *perfil de renda híbrido* (contracheque, recebimento único ou fluxo diário), ex.: \" +\n \"'minha renda agora é fluxo diário com meta de 5000', 'recebo um pix único de 4000 todo dia 10', \" +\n \"'meu salário bruto é 6000 com descontos de 800 dia 5': action SET_INCOME_PROFILE; \" +\n \"tipoPerfil = CONTRACHEQUE | RECEBIMENTO_UNICO | FLUXO_DIARIO; \" +\n \"CONTRACHEQUE: salarioBruto, descontosHolerite (total) ou descontosFixos em updates, diaRecebimento; \" +\n \"RECEBIMENTO_UNICO: valorLiquidoFixo, diaRecebimentoFixo; FLUXO_DIARIO: metaFaturamentoMensal (opcional). \" +\n \"Prefira SET_INCOME_PROFILE quando a frase citar explicitamente o *tipo* de trabalhador/perfil.\\n\" +\n \"- Se confirmar que provisão fiscal caiu na conta (ex.: 'confirma o 13', 'primeira parcela do 13 caiu', \" +\n \"'restituição do IR entrou na conta', 'efetiva o décimo terceiro'): action CONFIRM_FISCAL_PROVISION; \" +\n \"description = termo citado (13, restituição IR, etc.); accountName/bank se citar conta específica.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Listar assinaturas cadastradas/monitoradas (ex.: 'quais minhas assinaturas?', 'lista assinaturas'): action LIST_SUBSCRIPTIONS.\\n\" +\n \"- Cadastrar assinatura recorrente manualmente (ex.: 'assinei o plano do Spotify por 24,90 por mês', \" +\n \"'cadastra a assinatura da Netflix de 55,90 todo dia 15'): action CREATE_SUBSCRIPTION; description = nome; amount = valor mensal; dueDay = dia de cobrança (1-31).\\n\" +\n \"- Cadastrar despesa fixa/obrigação mensal (ex.: 'cadastra a despesa fixa de aluguel valor 1200 vencimento todo dia 10', \" +\n \"'salve essa despesa fixa de 250 para internet dia 10'): action CREATE_FIXED_EXPENSE; description = nome; amount = valor; dueDay = dia de vencimento.\\n\" +\n \"- Ativar ou desativar assinatura cadastrada (ex.: 'desative a assinatura da Netflix', 'pause a academia', 'reative o Spotify'): \" +\n \"action TOGGLE_SUBSCRIPTION; description ou searchPhrase = nome da assinatura; subscriptionActive=false para desativar/pausar, true para ativar.\\n\" +\n \"- Tutorial interativo opt-in (guia de uso do sistema): action START_TUTORIAL para frases como \" +\n \"'tutorial', 'como usar', 'me ensina a usar o sistema', 'como funciona isso aqui', 'ajuda', 'help', 'nao sei usar'.\\n\" +\n \"- START_TUTORIAL também quando o utilizador perguntar sobre uma aba/tela específica — preencha reportMonth com o capítulo (1-5) e entregue o capítulo direto, sem menu:\\n\" +\n \" 'como uso a aba de contas?' → reportMonth=1 · 'me explica a tela de renda' → reportMonth=4 · \" +\n \"'o que e a aba de metas?' → reportMonth=5 · 'nao entendo a aba de cartoes' → reportMonth=3 · \" +\n \"'como funciona a aba de transacoes?' → reportMonth=2.\\n\" +\n \"- Encerrar tutorial ativo: action STOP_TUTORIAL para 'sair', 'parar', 'desligar', 'cancelar', 'encerrar tutorial', 'chega', 'voltar ao normal'.\\n\" +\n \"- Navegar capítulo do tutorial (1 a 5) quando o utilizador está no guia: action TUTORIAL_STEP; preencher reportMonth com o número do capítulo (1-5) se útil.\\n\" +\n \"REGRAS DE TOLERÂNCIA A LINGUAGEM:\\n\" +\n \"Ao interpretar a mensagem do usuário, aplique normalizações ANTES de classificar a intenção.\\n\" +\n \"ABREVIAÇÕES FINANCEIRAS: nu/nub/nuba/nubank→Nubank; ita/itau/itá→Itaú; bb/bradesco→Bradesco; inter→Banco Inter; \" +\n \"xp/xpi→XP; cef/caixa/cx→Caixa; sntdr/santander→Santander.\\n\" +\n \"ESTABELECIMENTOS: mrcdo/mercd/mkt/feira/sacolão→Mercado/Alimentação; ubr/uber/99→Transporte; \" +\n \"ifood/ifd/rappi→Alimentação/Delivery; farm/farmci/drog→Farmácia; posto/gasolina/combust→Combustível; \" +\n \"academia/acad/gym→Academia; netf/netflix/spotify/prime→Assinaturas/Lazer.\\n\" +\n \"INFORMAIS: tô no vermelho/zerado/liso→saldo crítico; sobrou/sobra→consulta saldo; fechei/como tá o mês→relatório; \" +\n \"queimei/estourei→orçamento excedido; guardei/separei→poupança/meta; parcelei/em x vezes→parcelado.\\n\" +\n \"REGRAS: (1) corrija erros óbvios pelo contexto; (2) abreviação dupla→entidade_ambigua+opcoes_entidade; \" +\n \"(3) não invente entidade→entidade_desconhecida; (4) preencha nome_normalizado com o nome corrigido.\\n\" +\n \"Intenção GREETING: ative quando a mensagem for APENAS uma saudação informal brasileira \" +\n \"(oi, olá, iae, iai, e aí, salve, opa, fala, beleza, tudo bem, bom dia, boa tarde, boa noite), sem pedido financeiro.\\n\" +\n \"REGRA IMPORTANTE: se além da saudação houver um comando financeiro na mesma frase \" +\n \"(ex.: 'oi jarvis, quanto tenho na conta?'), classifique pela INTENÇÃO FINANCEIRA principal \" +\n \"(LIST_ACCOUNTS, CREATE_EXPENSE, etc.), NÃO como GREETING.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n /** Visão computacional para boleto bancário ou QR Code Pix (agendamento de pagamento). */\n public JsonNode analisarImagemBoletoPix(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para leitura de boleto/Pix\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você extrai dados de boleto bancário brasileiro ou QR Code Pix para agendamento de pagamento. \"\n + \"Retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise a imagem e extraia: \"\n + \"tipo (BOLETO|PIX|CUPOM_FISCAL|OUTRO), beneficiario (string), valor (número decimal), \"\n + \"dataVencimento (yyyy-MM-dd), linhaDigitavel ou codigoBarrasOuPix (se visível), \"\n + \"confianca (0 a 1), erro (string opcional). \"\n + \"Use tipo CUPOM_FISCAL se for nota fiscal de compra, não boleto. \"\n + \"Se não for possível ler com segurança, preencha erro e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId, systemPrompt, userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, dataUrl);\n },\n \"Falha leitura boleto/Pix em todos provedores: \"\n );\n log.info(\"[VISION-LOG] Boleto/Pix userId={} tipo={} confianca={}\",\n userId, out.path(\"tipo\").asText(\"\"), out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Narra o conselho financeiro — a IA só redige; todos os números já foram calculados em Java.\n */\n public String narrarConselho(Long userId, ResultadoConselho resultado) {\n if (resultado == null) {\n return \"Chefe, não consegui montar o cenário agora. Tenta de novo com valor e parcelas, se houver.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n String vereditoTxt = switch (resultado.getVeredito() != null ? resultado.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"RISCO_ALTO\";\n case SEGURO -> \"SEGURO\";\n case ATENCAO -> \"ATENCAO\";\n };\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Veredito: \").append(vereditoTxt).append(\"\\n\");\n dados.append(\"- Item: \").append(resultado.getDescricaoItem() != null ? resultado.getDescricaoItem() : \"item\").append(\"\\n\");\n if (resultado.getCustoTotal() != null) {\n dados.append(\"- Custo total: \").append(brl.format(resultado.getCustoTotal())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensal() != null && resultado.getTaxaJurosAnual() != null) {\n dados.append(\"- Taxa de juros: \").append(resultado.getTaxaJurosMensal()).append(\"% ao mês / \")\n .append(resultado.getTaxaJurosAnual()).append(\"% ao ano\\n\");\n }\n if (resultado.getPercentualRendaComprometida() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPercentualRendaComprometida()).append(\"%\\n\");\n }\n if (resultado.getSaldoAposCompra() != null) {\n dados.append(\"- Saldo após a compra: \").append(brl.format(resultado.getSaldoAposCompra())).append(\"\\n\");\n }\n if (resultado.getReservaMesesApos() != null) {\n dados.append(\"- Reserva após a compra: \").append(resultado.getReservaMesesApos()).append(\" meses\\n\");\n }\n if (resultado.getMesesReservaAtual() != null) {\n dados.append(\"- Reserva atual (Escudo): \").append(resultado.getMesesReservaAtual()).append(\" meses\\n\");\n }\n if (resultado.getComparacaoMercado() != null) {\n dados.append(\"- Comparação com o mercado: \").append(resultado.getComparacaoMercado());\n if (resultado.getTaxaMercadoReferencia() != null) {\n dados.append(\" (referência \").append(resultado.getTaxaMercadoReferencia()).append(\"% a.a.)\");\n }\n dados.append(\"\\n\");\n }\n if (resultado.isAvisoSemValorTomado()) {\n dados.append(\"- Aviso: valor tomado/à vista não informado — juros não calculados\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S., estrategista financeiro pessoal do chefe. Os cálculos JÁ FORAM FEITOS.\\n\"\n + \"Sua tarefa é APENAS transformar os dados abaixo em uma resposta clara, direta e humana.\\n\"\n + \"NÃO recalcule nada. NÃO invente números. Use exatamente os valores fornecidos.\\n\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Comece com o selo do veredito: 🔴 *[RISCO ALTO]* | 🟡 *[ATENÇÃO]* | 🟢 *[PODE IR]*\\n\"\n + \"2. Explique o PORQUÊ em linguagem simples, sem jargão técnico.\\n\"\n + \"3. Se houver juros, mostre o impacto concreto no bolso.\\n\"\n + \"4. Para parcelas, diga quantos % da renda ficam comprometidos.\\n\"\n + \"5. Em decisões grandes, feche lembrando que a decisão é do chefe.\\n\"\n + \"6. Tom: direto, honesto, protetor do bolso. Chame de chefe.\\n\"\n + \"7. Máximo 2 emojis além do selo.\\n\"\n + \"8. Orientação baseada nos dados cadastrados — não é garantia nem consultoria profissional.\\n\";\n String userPrompt = \"DADOS CALCULADOS:\\n\" + dados;\n String fallback = montarFallbackDeterministico(resultado, brl);\n return gerarTexto(userId, system, userPrompt, fallback);\n }\n\n /**\n * Narra o registro de empréstimo consignado — números já calculados em Java.\n */\n public String narrarRegistroEmprestimo(Long userId, ResultadoRegistroEmprestimo resultado) {\n if (resultado == null) {\n return \"Chefe, não consegui concluir o registro do empréstimo agora.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Valor creditado: \").append(brl.format(resultado.getValorTomado())).append(\"\\n\");\n if (resultado.getContaNome() != null) {\n dados.append(\"- Conta: \").append(resultado.getContaNome()).append(\"\\n\");\n }\n if (resultado.getNovoSaldoConta() != null) {\n dados.append(\"- Novo saldo da conta: \").append(brl.format(resultado.getNovoSaldoConta())).append(\"\\n\");\n }\n dados.append(\"- Parcela: \").append(brl.format(resultado.getValorParcela()));\n if (resultado.isParcelaEstimada()) {\n dados.append(\" (estimada pela taxa média de mercado)\");\n }\n dados.append(\"\\n\");\n dados.append(\"- Nº de parcelas: \").append(resultado.getQuantidadeParcelas());\n if (resultado.getDataPrimeiraParcela() != null && resultado.getDataUltimaParcela() != null) {\n dados.append(\" (\").append(resultado.getDataPrimeiraParcela())\n .append(\" até \").append(resultado.getDataUltimaParcela()).append(\")\\n\");\n } else {\n dados.append(\"\\n\");\n }\n if (resultado.getTotalAPagar() != null) {\n dados.append(\"- Total a pagar: \").append(brl.format(resultado.getTotalAPagar())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensalPct() != null && resultado.getTaxaJurosAnualPct() != null) {\n dados.append(\"- Taxa efetiva: \").append(resultado.getTaxaJurosMensalPct())\n .append(\"% a.m. / \").append(resultado.getTaxaJurosAnualPct()).append(\"% a.a.\\n\");\n }\n if (resultado.getPctRendaComprometidaDepois() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPctRendaComprometidaDepois()).append(\"%\\n\");\n }\n if (resultado.getRendaLivreAntes() != null && resultado.getRendaLivreDepois() != null) {\n dados.append(\"- Renda livre: era \").append(brl.format(resultado.getRendaLivreAntes()))\n .append(\", ficou \").append(brl.format(resultado.getRendaLivreDepois())).append(\"\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S. Os cálculos JÁ FORAM FEITOS — NÃO recalcule.\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Confirme o crédito e o novo saldo primeiro.\\n\"\n + \"2. Mostre o custo real: quanto recebe vs quanto devolve e juros.\\n\"\n + \"3. Diga quanto da renda fica comprometido e por quantos meses.\\n\"\n + \"4. Se parcela estimada, avise para informar valor exato do contrato.\\n\"\n + \"5. Linguagem simples. Chame de chefe. Máximo 2 emojis.\\n\"\n + \"6. Feche lembrando que dá pra acompanhar na aba Transações.\\n\";\n String fallback = montarFallbackRegistroEmprestimo(resultado, brl);\n return gerarTexto(userId, system, \"DADOS CALCULADOS (não recalcule, apenas redija):\\n\" + dados, fallback);\n }\n\n private static String montarFallbackRegistroEmprestimo(ResultadoRegistroEmprestimo r, NumberFormat brl) {\n StringBuilder sb = new StringBuilder();\n sb.append(\"Feito, chefe! Creditei *\").append(brl.format(r.getValorTomado())).append(\"*\");\n if (r.getContaNome() != null) {\n sb.append(\" na *\").append(r.getContaNome()).append(\"*\");\n }\n if (r.getNovoSaldoConta() != null) {\n sb.append(\" — saldo agora *\").append(brl.format(r.getNovoSaldoConta())).append(\"*\");\n }\n sb.append(\".\\n\");\n sb.append(\"São *\").append(r.getQuantidadeParcelas()).append(\"x de \")\n .append(brl.format(r.getValorParcela()));\n if (r.isParcelaEstimada()) {\n sb.append(\" _(estimada — me passe o valor exato do contrato pra ajustar)_\");\n }\n sb.append(\".\\n\");\n if (r.getJurosTotais() != null && r.getTotalAPagar() != null) {\n sb.append(\"Você recebe \").append(brl.format(r.getValorTomado()))\n .append(\", mas devolve \").append(brl.format(r.getTotalAPagar()))\n .append(\" — \").append(brl.format(r.getJurosTotais())).append(\" de juros.\\n\");\n }\n if (r.getPctRendaComprometidaDepois() != null) {\n sb.append(\"Comprometimento da renda: *\").append(r.getPctRendaComprometidaDepois()).append(\"%*.\\n\");\n }\n sb.append(\"Acompanhe as parcelas em *Transações*.\");\n return sb.toString();\n }\n\n private static String montarFallbackDeterministico(ResultadoConselho r, NumberFormat brl) {\n String selo = switch (r.getVeredito() != null ? r.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"🔴 *[RISCO ALTO]*\";\n case SEGURO -> \"🟢 *[PODE IR]*\";\n case ATENCAO -> \"🟡 *[ATENÇÃO]*\";\n };\n StringBuilder sb = new StringBuilder(selo).append(\" \");\n sb.append(\"Sobre \").append(r.getDescricaoItem() != null ? r.getDescricaoItem() : \"essa operação\").append(\": \");\n if (r.getCustoTotal() != null) {\n sb.append(\"custo total \").append(brl.format(r.getCustoTotal())).append(\". \");\n }\n if (r.getJurosTotais() != null && r.getJurosTotais().compareTo(BigDecimal.ZERO) > 0) {\n sb.append(\"Juros: \").append(brl.format(r.getJurosTotais())).append(\". \");\n }\n if (r.getPercentualRendaComprometida() != null) {\n sb.append(\"Compromete \").append(r.getPercentualRendaComprometida()).append(\"% da sua renda. \");\n }\n if (r.getSaldoAposCompra() != null) {\n sb.append(\"Saldo após compra: \").append(brl.format(r.getSaldoAposCompra())).append(\". \");\n }\n sb.append(\"A palavra final é sua, chefe.\");\n return sb.toString();\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "e76b643724b48a6beb2ea2beb495182b5a1fe97f": {"t": 1785752810.303588, "n": 67182, "h": "6d34b6b460c137f5", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.ResultadoConselho;\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.model.Veredito;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.text.NumberFormat;\nimport java.util.ArrayList;\nimport java.util.Locale;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private JarvisContextoFinanceiroService jarvisContextoFinanceiroService;\n\n private RestTemplate restTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null || jarvisContextoFinanceiroService == null) {\n return \"\";\n }\n try {\n return jarvisContextoFinanceiroService.montarBlocoContexto(userId);\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|CREATE_FIXED_EXPENSE|CREATE_SUBSCRIPTION|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_FINANCIAL_ADVICE|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|LIST_ACCOUNTS|TRANSFER_BETWEEN_ACCOUNTS|LIST_TRANSACTIONS|LIST_CATEGORIES|LIST_METAS|GET_REPORT_SUMMARY|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|SET_INCOME_PROFILE|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|LIST_SUBSCRIPTIONS|TOGGLE_SUBSCRIPTION|START_TUTORIAL|STOP_TUTORIAL|TUTORIAL_STEP|GREETING|UNKNOWN), \" +\n \"contaOrigem (apelido/nome da conta de origem na transferência), contaDestino (apelido/nome da conta de destino na transferência), \" +\n \"mes (1-12, opcional — consultas de extrato/orçamento), ano (ex.: 2026, opcional), tipo (DESPESA|RECEITA, opcional — filtro de extrato), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento|despesa_fixa|assinatura), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"subscriptionActive (true para ativar/reativar assinatura cadastrada; false para desativar/pausar), \" +\n \"tipoPerfil (CONTRACHEQUE|RECEBIMENTO_UNICO|FLUXO_DIARIO — perfil híbrido de renda), \" +\n \"salarioBruto, descontosHolerite (total ou use descontosFixos em updates), diaRecebimento (1-31, CONTRACHEQUE), \" +\n \"valorLiquidoFixo, diaRecebimentoFixo (RECEBIMENTO_UNICO), metaFaturamentoMensal (FLUXO_DIARIO), \" +\n \"tipoOperacao (COMPRA_AVISTA|COMPRA_PARCELADA|EMPRESTIMO|CONSIGNADO|FINANCIAMENTO — conselho de compra/empréstimo), \" +\n \"valorTotal (valor do bem ou valor tomado no empréstimo), valorParcela (valor de cada parcela), \" +\n \"quantidadeParcelas (número de parcelas), descricaoItem (ex.: moto, tênis, consignado), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"nome_normalizado (nome corrigido da entidade identificada), entidade_ambigua (true se abreviação dupla), \" +\n \"entidade_desconhecida (true se não reconhecer), opcoes_entidade (array de nomes quando ambíguo), \" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount; categoryName quando o estabelecimento \" +\n \"implicar categoria (ex.: ubr/uber→Transporte, mrcdo/mercado→Alimentação); nome_normalizado nas entidades.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor, dia_vencimento (1-31).\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se pedir conselho sobre compra, empréstimo, consignado ou financiamento (ex.: 'vale a pena comprar moto de 15k parcelada?', \" +\n \"'consignado de 10 mil em 24x de 550 o que acha?', 'posso comprar um tênis de 800 hoje?', 'devo pegar esse empréstimo?'): \" +\n \"action GET_FINANCIAL_ADVICE; preencha tipoOperacao, valorTotal, valorParcela, quantidadeParcelas, descricaoItem. \" +\n \"Normalização numérica: '15k'/'15 mil'→15000; '10.000'→10000; '24x de 550'→quantidadeParcelas=24, valorParcela=550; \" +\n \"'550 por mês durante 2 anos'→valorParcela=550, quantidadeParcelas=24. \" +\n \"Se citar parcelas/consignado/financiamento→operação parcelada; 'à vista'/'hoje'/'agora' sem parcelas→COMPRA_AVISTA. \" +\n \"NÃO calcule juros nem veredito — apenas extraia parâmetros.\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se pedir saldos das contas, patrimônio em contas ou quanto tem nas contas (ex.: 'quanto eu tenho nas contas?', \" +\n \"'saldos das contas', 'meu patrimônio', 'lista minhas contas', 'saldo do nu', 'saldo da conta Nubank'): \" +\n \"action LIST_ACCOUNTS; preencher accountName ou bank com o apelido citado; nome_normalizado com o nome corrigido.\\n\" +\n \"- Se pedir transferir/mover/passar valor entre contas bancárias (ex.: 'transfere 100 do Itaú pro Nubank', \" +\n \"'passa 50 reais da conta Inter para a poupança', 'manda 200 da corrente pra carteira'): action TRANSFER_BETWEEN_ACCOUNTS; \" +\n \"preencher amount (valor), contaOrigem e contaDestino com os apelidos citados; NÃO use CREATE_EXPENSE para transferências internas.\\n\" +\n \"- Se pedir extrato, últimos gastos/receitas ou listar transações (ex.: 'quais foram meus últimos gastos?', \" +\n \"'lista os pix que recebi em junho', 'o que gastei na categoria mercado esse mês?'): action LIST_TRANSACTIONS; \" +\n \"preencher mes/ano ou reportMonth/reportYear quando citar período; tipo=DESPESA ou RECEITA se filtrar; categoryName/categoria se citar categoria.\\n\" +\n \"- Se pedir listar categorias cadastradas (ex.: 'quais categorias eu tenho?', 'lista minhas categorias'): action LIST_CATEGORIES.\\n\" +\n \"- Se pedir estado das metas/objetivos (ex.: 'como estão minhas metas?', 'quais são os meus objetivos de economia?'): action LIST_METAS.\\n\" +\n \"- Se pedir resumo de orçamento, limites por categoria ou gasto vs limite (ex.: 'quanto gastei do meu orçamento?', \" +\n \"'resumo de gastos do orçamento', 'meus limites de categoria'): action GET_REPORT_SUMMARY; mes/ano se citar mês.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação', \" +\n \"'apague despesa fixa aluguel', 'remova assinatura Netflix'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento | despesa_fixa | assinatura; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se configurar *perfil de renda híbrido* (contracheque, recebimento único ou fluxo diário), ex.: \" +\n \"'minha renda agora é fluxo diário com meta de 5000', 'recebo um pix único de 4000 todo dia 10', \" +\n \"'meu salário bruto é 6000 com descontos de 800 dia 5': action SET_INCOME_PROFILE; \" +\n \"tipoPerfil = CONTRACHEQUE | RECEBIMENTO_UNICO | FLUXO_DIARIO; \" +\n \"CONTRACHEQUE: salarioBruto, descontosHolerite (total) ou descontosFixos em updates, diaRecebimento; \" +\n \"RECEBIMENTO_UNICO: valorLiquidoFixo, diaRecebimentoFixo; FLUXO_DIARIO: metaFaturamentoMensal (opcional). \" +\n \"Prefira SET_INCOME_PROFILE quando a frase citar explicitamente o *tipo* de trabalhador/perfil.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Listar assinaturas cadastradas/monitoradas (ex.: 'quais minhas assinaturas?', 'lista assinaturas'): action LIST_SUBSCRIPTIONS.\\n\" +\n \"- Cadastrar assinatura recorrente manualmente (ex.: 'assinei o plano do Spotify por 24,90 por mês', \" +\n \"'cadastra a assinatura da Netflix de 55,90 todo dia 15'): action CREATE_SUBSCRIPTION; description = nome; amount = valor mensal; dueDay = dia de cobrança (1-31).\\n\" +\n \"- Cadastrar despesa fixa/obrigação mensal (ex.: 'cadastra a despesa fixa de aluguel valor 1200 vencimento todo dia 10', \" +\n \"'salve essa despesa fixa de 250 para internet dia 10'): action CREATE_FIXED_EXPENSE; description = nome; amount = valor; dueDay = dia de vencimento.\\n\" +\n \"- Ativar ou desativar assinatura cadastrada (ex.: 'desative a assinatura da Netflix', 'pause a academia', 'reative o Spotify'): \" +\n \"action TOGGLE_SUBSCRIPTION; description ou searchPhrase = nome da assinatura; subscriptionActive=false para desativar/pausar, true para ativar.\\n\" +\n \"- Tutorial interativo opt-in (guia de uso do sistema): action START_TUTORIAL para frases como \" +\n \"'tutorial', 'como usar', 'me ensina a usar o sistema', 'como funciona isso aqui', 'ajuda', 'help', 'nao sei usar'.\\n\" +\n \"- START_TUTORIAL também quando o utilizador perguntar sobre uma aba/tela específica — preencha reportMonth com o capítulo (1-5) e entregue o capítulo direto, sem menu:\\n\" +\n \" 'como uso a aba de contas?' → reportMonth=1 · 'me explica a tela de renda' → reportMonth=4 · \" +\n \"'o que e a aba de metas?' → reportMonth=5 · 'nao entendo a aba de cartoes' → reportMonth=3 · \" +\n \"'como funciona a aba de transacoes?' → reportMonth=2.\\n\" +\n \"- Encerrar tutorial ativo: action STOP_TUTORIAL para 'sair', 'parar', 'desligar', 'cancelar', 'encerrar tutorial', 'chega', 'voltar ao normal'.\\n\" +\n \"- Navegar capítulo do tutorial (1 a 5) quando o utilizador está no guia: action TUTORIAL_STEP; preencher reportMonth com o número do capítulo (1-5) se útil.\\n\" +\n \"REGRAS DE TOLERÂNCIA A LINGUAGEM:\\n\" +\n \"Ao interpretar a mensagem do usuário, aplique normalizações ANTES de classificar a intenção.\\n\" +\n \"ABREVIAÇÕES FINANCEIRAS: nu/nub/nuba/nubank→Nubank; ita/itau/itá→Itaú; bb/bradesco→Bradesco; inter→Banco Inter; \" +\n \"xp/xpi→XP; cef/caixa/cx→Caixa; sntdr/santander→Santander.\\n\" +\n \"ESTABELECIMENTOS: mrcdo/mercd/mkt/feira/sacolão→Mercado/Alimentação; ubr/uber/99→Transporte; \" +\n \"ifood/ifd/rappi→Alimentação/Delivery; farm/farmci/drog→Farmácia; posto/gasolina/combust→Combustível; \" +\n \"academia/acad/gym→Academia; netf/netflix/spotify/prime→Assinaturas/Lazer.\\n\" +\n \"INFORMAIS: tô no vermelho/zerado/liso→saldo crítico; sobrou/sobra→consulta saldo; fechei/como tá o mês→relatório; \" +\n \"queimei/estourei→orçamento excedido; guardei/separei→poupança/meta; parcelei/em x vezes→parcelado.\\n\" +\n \"REGRAS: (1) corrija erros óbvios pelo contexto; (2) abreviação dupla→entidade_ambigua+opcoes_entidade; \" +\n \"(3) não invente entidade→entidade_desconhecida; (4) preencha nome_normalizado com o nome corrigido.\\n\" +\n \"Intenção GREETING: ative quando a mensagem for APENAS uma saudação informal brasileira \" +\n \"(oi, olá, iae, iai, e aí, salve, opa, fala, beleza, tudo bem, bom dia, boa tarde, boa noite), sem pedido financeiro.\\n\" +\n \"REGRA IMPORTANTE: se além da saudação houver um comando financeiro na mesma frase \" +\n \"(ex.: 'oi jarvis, quanto tenho na conta?'), classifique pela INTENÇÃO FINANCEIRA principal \" +\n \"(LIST_ACCOUNTS, CREATE_EXPENSE, etc.), NÃO como GREETING.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n /** Visão computacional para boleto bancário ou QR Code Pix (agendamento de pagamento). */\n public JsonNode analisarImagemBoletoPix(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para leitura de boleto/Pix\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você extrai dados de boleto bancário brasileiro ou QR Code Pix para agendamento de pagamento. \"\n + \"Retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise a imagem e extraia: \"\n + \"tipo (BOLETO|PIX|CUPOM_FISCAL|OUTRO), beneficiario (string), valor (número decimal), \"\n + \"dataVencimento (yyyy-MM-dd), linhaDigitavel ou codigoBarrasOuPix (se visível), \"\n + \"confianca (0 a 1), erro (string opcional). \"\n + \"Use tipo CUPOM_FISCAL se for nota fiscal de compra, não boleto. \"\n + \"Se não for possível ler com segurança, preencha erro e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId, systemPrompt, userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, dataUrl);\n },\n \"Falha leitura boleto/Pix em todos provedores: \"\n );\n log.info(\"[VISION-LOG] Boleto/Pix userId={} tipo={} confianca={}\",\n userId, out.path(\"tipo\").asText(\"\"), out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Narra o conselho financeiro — a IA só redige; todos os números já foram calculados em Java.\n */\n public String narrarConselho(Long userId, ResultadoConselho resultado) {\n if (resultado == null) {\n return \"Chefe, não consegui montar o cenário agora. Tenta de novo com valor e parcelas, se houver.\";\n }\n NumberFormat brl = NumberFormat.getCurrencyInstance(new Locale(\"pt\", \"BR\"));\n String vereditoTxt = switch (resultado.getVeredito() != null ? resultado.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"RISCO_ALTO\";\n case SEGURO -> \"SEGURO\";\n case ATENCAO -> \"ATENCAO\";\n };\n StringBuilder dados = new StringBuilder();\n dados.append(\"- Veredito: \").append(vereditoTxt).append(\"\\n\");\n dados.append(\"- Item: \").append(resultado.getDescricaoItem() != null ? resultado.getDescricaoItem() : \"item\").append(\"\\n\");\n if (resultado.getCustoTotal() != null) {\n dados.append(\"- Custo total: \").append(brl.format(resultado.getCustoTotal())).append(\"\\n\");\n }\n if (resultado.getJurosTotais() != null) {\n dados.append(\"- Juros totais: \").append(brl.format(resultado.getJurosTotais())).append(\"\\n\");\n }\n if (resultado.getTaxaJurosMensal() != null && resultado.getTaxaJurosAnual() != null) {\n dados.append(\"- Taxa de juros: \").append(resultado.getTaxaJurosMensal()).append(\"% ao mês / \")\n .append(resultado.getTaxaJurosAnual()).append(\"% ao ano\\n\");\n }\n if (resultado.getPercentualRendaComprometida() != null) {\n dados.append(\"- Comprometimento da renda: \").append(resultado.getPercentualRendaComprometida()).append(\"%\\n\");\n }\n if (resultado.getSaldoAposCompra() != null) {\n dados.append(\"- Saldo após a compra: \").append(brl.format(resultado.getSaldoAposCompra())).append(\"\\n\");\n }\n if (resultado.getReservaMesesApos() != null) {\n dados.append(\"- Reserva após a compra: \").append(resultado.getReservaMesesApos()).append(\" meses\\n\");\n }\n if (resultado.getMesesReservaAtual() != null) {\n dados.append(\"- Reserva atual (Escudo): \").append(resultado.getMesesReservaAtual()).append(\" meses\\n\");\n }\n if (resultado.getComparacaoMercado() != null) {\n dados.append(\"- Comparação com o mercado: \").append(resultado.getComparacaoMercado());\n if (resultado.getTaxaMercadoReferencia() != null) {\n dados.append(\" (referência \").append(resultado.getTaxaMercadoReferencia()).append(\"% a.a.)\");\n }\n dados.append(\"\\n\");\n }\n if (resultado.isAvisoSemValorTomado()) {\n dados.append(\"- Aviso: valor tomado/à vista não informado — juros não calculados\\n\");\n }\n\n Usuario u = userId == null ? null : usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Você é o J.A.R.V.I.S., estrategista financeiro pessoal do chefe. Os cálculos JÁ FORAM FEITOS.\\n\"\n + \"Sua tarefa é APENAS transformar os dados abaixo em uma resposta clara, direta e humana.\\n\"\n + \"NÃO recalcule nada. NÃO invente números. Use exatamente os valores fornecidos.\\n\\n\"\n + \"REGRAS DE REDAÇÃO:\\n\"\n + \"1. Comece com o selo do veredito: 🔴 *[RISCO ALTO]* | 🟡 *[ATENÇÃO]* | 🟢 *[PODE IR]*\\n\"\n + \"2. Explique o PORQUÊ em linguagem simples, sem jargão técnico.\\n\"\n + \"3. Se houver juros, mostre o impacto concreto no bolso.\\n\"\n + \"4. Para parcelas, diga quantos % da renda ficam comprometidos.\\n\"\n + \"5. Em decisões grandes, feche lembrando que a decisão é do chefe.\\n\"\n + \"6. Tom: direto, honesto, protetor do bolso. Chame de chefe.\\n\"\n + \"7. Máximo 2 emojis além do selo.\\n\"\n + \"8. Orientação baseada nos dados cadastrados — não é garantia nem consultoria profissional.\\n\";\n String userPrompt = \"DADOS CALCULADOS:\\n\" + dados;\n String fallback = montarFallbackDeterministico(resultado, brl);\n return gerarTexto(userId, system, userPrompt, fallback);\n }\n\n private static String montarFallbackDeterministico(ResultadoConselho r, NumberFormat brl) {\n String selo = switch (r.getVeredito() != null ? r.getVeredito() : Veredito.ATENCAO) {\n case RISCO_ALTO -> \"🔴 *[RISCO ALTO]*\";\n case SEGURO -> \"🟢 *[PODE IR]*\";\n case ATENCAO -> \"🟡 *[ATENÇÃO]*\";\n };\n StringBuilder sb = new StringBuilder(selo).append(\" \");\n sb.append(\"Sobre \").append(r.getDescricaoItem() != null ? r.getDescricaoItem() : \"essa operação\").append(\": \");\n if (r.getCustoTotal() != null) {\n sb.append(\"custo total \").append(brl.format(r.getCustoTotal())).append(\". \");\n }\n if (r.getJurosTotais() != null && r.getJurosTotais().compareTo(BigDecimal.ZERO) > 0) {\n sb.append(\"Juros: \").append(brl.format(r.getJurosTotais())).append(\". \");\n }\n if (r.getPercentualRendaComprometida() != null) {\n sb.append(\"Compromete \").append(r.getPercentualRendaComprometida()).append(\"% da sua renda. \");\n }\n if (r.getSaldoAposCompra() != null) {\n sb.append(\"Saldo após compra: \").append(brl.format(r.getSaldoAposCompra())).append(\". \");\n }\n sb.append(\"A palavra final é sua, chefe.\");\n return sb.toString();\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "994135a090491b802afdfe3eaa21f6ceb11b589c": {"t": 1785752812.8913016, "n": 60188, "h": "7bc6f2cfa1fb5dcc", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private JarvisContextoFinanceiroService jarvisContextoFinanceiroService;\n\n private RestTemplate restTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null || jarvisContextoFinanceiroService == null) {\n return \"\";\n }\n try {\n return jarvisContextoFinanceiroService.montarBlocoContexto(userId);\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|CREATE_FIXED_EXPENSE|CREATE_SUBSCRIPTION|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|LIST_ACCOUNTS|TRANSFER_BETWEEN_ACCOUNTS|LIST_TRANSACTIONS|LIST_CATEGORIES|LIST_METAS|GET_REPORT_SUMMARY|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|SET_INCOME_PROFILE|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|LIST_SUBSCRIPTIONS|TOGGLE_SUBSCRIPTION|START_TUTORIAL|STOP_TUTORIAL|TUTORIAL_STEP|GREETING|UNKNOWN), \" +\n \"contaOrigem (apelido/nome da conta de origem na transferência), contaDestino (apelido/nome da conta de destino na transferência), \" +\n \"mes (1-12, opcional — consultas de extrato/orçamento), ano (ex.: 2026, opcional), tipo (DESPESA|RECEITA, opcional — filtro de extrato), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento|despesa_fixa|assinatura), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"subscriptionActive (true para ativar/reativar assinatura cadastrada; false para desativar/pausar), \" +\n \"tipoPerfil (CONTRACHEQUE|RECEBIMENTO_UNICO|FLUXO_DIARIO — perfil híbrido de renda), \" +\n \"salarioBruto, descontosHolerite (total ou use descontosFixos em updates), diaRecebimento (1-31, CONTRACHEQUE), \" +\n \"valorLiquidoFixo, diaRecebimentoFixo (RECEBIMENTO_UNICO), metaFaturamentoMensal (FLUXO_DIARIO), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"nome_normalizado (nome corrigido da entidade identificada), entidade_ambigua (true se abreviação dupla), \" +\n \"entidade_desconhecida (true se não reconhecer), opcoes_entidade (array de nomes quando ambíguo), \" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount; categoryName quando o estabelecimento \" +\n \"implicar categoria (ex.: ubr/uber→Transporte, mrcdo/mercado→Alimentação); nome_normalizado nas entidades.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor, dia_vencimento (1-31).\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se pedir saldos das contas, patrimônio em contas ou quanto tem nas contas (ex.: 'quanto eu tenho nas contas?', \" +\n \"'saldos das contas', 'meu patrimônio', 'lista minhas contas', 'saldo do nu', 'saldo da conta Nubank'): \" +\n \"action LIST_ACCOUNTS; preencher accountName ou bank com o apelido citado; nome_normalizado com o nome corrigido.\\n\" +\n \"- Se pedir transferir/mover/passar valor entre contas bancárias (ex.: 'transfere 100 do Itaú pro Nubank', \" +\n \"'passa 50 reais da conta Inter para a poupança', 'manda 200 da corrente pra carteira'): action TRANSFER_BETWEEN_ACCOUNTS; \" +\n \"preencher amount (valor), contaOrigem e contaDestino com os apelidos citados; NÃO use CREATE_EXPENSE para transferências internas.\\n\" +\n \"- Se pedir extrato, últimos gastos/receitas ou listar transações (ex.: 'quais foram meus últimos gastos?', \" +\n \"'lista os pix que recebi em junho', 'o que gastei na categoria mercado esse mês?'): action LIST_TRANSACTIONS; \" +\n \"preencher mes/ano ou reportMonth/reportYear quando citar período; tipo=DESPESA ou RECEITA se filtrar; categoryName/categoria se citar categoria.\\n\" +\n \"- Se pedir listar categorias cadastradas (ex.: 'quais categorias eu tenho?', 'lista minhas categorias'): action LIST_CATEGORIES.\\n\" +\n \"- Se pedir estado das metas/objetivos (ex.: 'como estão minhas metas?', 'quais são os meus objetivos de economia?'): action LIST_METAS.\\n\" +\n \"- Se pedir resumo de orçamento, limites por categoria ou gasto vs limite (ex.: 'quanto gastei do meu orçamento?', \" +\n \"'resumo de gastos do orçamento', 'meus limites de categoria'): action GET_REPORT_SUMMARY; mes/ano se citar mês.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação', \" +\n \"'apague despesa fixa aluguel', 'remova assinatura Netflix'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento | despesa_fixa | assinatura; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se configurar *perfil de renda híbrido* (contracheque, recebimento único ou fluxo diário), ex.: \" +\n \"'minha renda agora é fluxo diário com meta de 5000', 'recebo um pix único de 4000 todo dia 10', \" +\n \"'meu salário bruto é 6000 com descontos de 800 dia 5': action SET_INCOME_PROFILE; \" +\n \"tipoPerfil = CONTRACHEQUE | RECEBIMENTO_UNICO | FLUXO_DIARIO; \" +\n \"CONTRACHEQUE: salarioBruto, descontosHolerite (total) ou descontosFixos em updates, diaRecebimento; \" +\n \"RECEBIMENTO_UNICO: valorLiquidoFixo, diaRecebimentoFixo; FLUXO_DIARIO: metaFaturamentoMensal (opcional). \" +\n \"Prefira SET_INCOME_PROFILE quando a frase citar explicitamente o *tipo* de trabalhador/perfil.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Listar assinaturas cadastradas/monitoradas (ex.: 'quais minhas assinaturas?', 'lista assinaturas'): action LIST_SUBSCRIPTIONS.\\n\" +\n \"- Cadastrar assinatura recorrente manualmente (ex.: 'assinei o plano do Spotify por 24,90 por mês', \" +\n \"'cadastra a assinatura da Netflix de 55,90 todo dia 15'): action CREATE_SUBSCRIPTION; description = nome; amount = valor mensal; dueDay = dia de cobrança (1-31).\\n\" +\n \"- Cadastrar despesa fixa/obrigação mensal (ex.: 'cadastra a despesa fixa de aluguel valor 1200 vencimento todo dia 10', \" +\n \"'salve essa despesa fixa de 250 para internet dia 10'): action CREATE_FIXED_EXPENSE; description = nome; amount = valor; dueDay = dia de vencimento.\\n\" +\n \"- Ativar ou desativar assinatura cadastrada (ex.: 'desative a assinatura da Netflix', 'pause a academia', 'reative o Spotify'): \" +\n \"action TOGGLE_SUBSCRIPTION; description ou searchPhrase = nome da assinatura; subscriptionActive=false para desativar/pausar, true para ativar.\\n\" +\n \"- Tutorial interativo opt-in (guia de uso do sistema): action START_TUTORIAL para frases como \" +\n \"'tutorial', 'como usar', 'me ensina a usar o sistema', 'como funciona isso aqui', 'ajuda', 'help', 'nao sei usar'.\\n\" +\n \"- START_TUTORIAL também quando o utilizador perguntar sobre uma aba/tela específica — preencha reportMonth com o capítulo (1-5) e entregue o capítulo direto, sem menu:\\n\" +\n \" 'como uso a aba de contas?' → reportMonth=1 · 'me explica a tela de renda' → reportMonth=4 · \" +\n \"'o que e a aba de metas?' → reportMonth=5 · 'nao entendo a aba de cartoes' → reportMonth=3 · \" +\n \"'como funciona a aba de transacoes?' → reportMonth=2.\\n\" +\n \"- Encerrar tutorial ativo: action STOP_TUTORIAL para 'sair', 'parar', 'desligar', 'cancelar', 'encerrar tutorial', 'chega', 'voltar ao normal'.\\n\" +\n \"- Navegar capítulo do tutorial (1 a 5) quando o utilizador está no guia: action TUTORIAL_STEP; preencher reportMonth com o número do capítulo (1-5) se útil.\\n\" +\n \"REGRAS DE TOLERÂNCIA A LINGUAGEM:\\n\" +\n \"Ao interpretar a mensagem do usuário, aplique normalizações ANTES de classificar a intenção.\\n\" +\n \"ABREVIAÇÕES FINANCEIRAS: nu/nub/nuba/nubank→Nubank; ita/itau/itá→Itaú; bb/bradesco→Bradesco; inter→Banco Inter; \" +\n \"xp/xpi→XP; cef/caixa/cx→Caixa; sntdr/santander→Santander.\\n\" +\n \"ESTABELECIMENTOS: mrcdo/mercd/mkt/feira/sacolão→Mercado/Alimentação; ubr/uber/99→Transporte; \" +\n \"ifood/ifd/rappi→Alimentação/Delivery; farm/farmci/drog→Farmácia; posto/gasolina/combust→Combustível; \" +\n \"academia/acad/gym→Academia; netf/netflix/spotify/prime→Assinaturas/Lazer.\\n\" +\n \"INFORMAIS: tô no vermelho/zerado/liso→saldo crítico; sobrou/sobra→consulta saldo; fechei/como tá o mês→relatório; \" +\n \"queimei/estourei→orçamento excedido; guardei/separei→poupança/meta; parcelei/em x vezes→parcelado.\\n\" +\n \"REGRAS: (1) corrija erros óbvios pelo contexto; (2) abreviação dupla→entidade_ambigua+opcoes_entidade; \" +\n \"(3) não invente entidade→entidade_desconhecida; (4) preencha nome_normalizado com o nome corrigido.\\n\" +\n \"Intenção GREETING: ative quando a mensagem for APENAS uma saudação informal brasileira \" +\n \"(oi, olá, iae, iai, e aí, salve, opa, fala, beleza, tudo bem, bom dia, boa tarde, boa noite), sem pedido financeiro.\\n\" +\n \"REGRA IMPORTANTE: se além da saudação houver um comando financeiro na mesma frase \" +\n \"(ex.: 'oi jarvis, quanto tenho na conta?'), classifique pela INTENÇÃO FINANCEIRA principal \" +\n \"(LIST_ACCOUNTS, CREATE_EXPENSE, etc.), NÃO como GREETING.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n /** Visão computacional para boleto bancário ou QR Code Pix (agendamento de pagamento). */\n public JsonNode analisarImagemBoletoPix(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para leitura de boleto/Pix\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você extrai dados de boleto bancário brasileiro ou QR Code Pix para agendamento de pagamento. \"\n + \"Retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise a imagem e extraia: \"\n + \"tipo (BOLETO|PIX|CUPOM_FISCAL|OUTRO), beneficiario (string), valor (número decimal), \"\n + \"dataVencimento (yyyy-MM-dd), linhaDigitavel ou codigoBarrasOuPix (se visível), \"\n + \"confianca (0 a 1), erro (string opcional). \"\n + \"Use tipo CUPOM_FISCAL se for nota fiscal de compra, não boleto. \"\n + \"Se não for possível ler com segurança, preencha erro e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId, systemPrompt, userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, dataUrl);\n },\n \"Falha leitura boleto/Pix em todos provedores: \"\n );\n log.info(\"[VISION-LOG] Boleto/Pix userId={} tipo={} confianca={}\",\n userId, out.path(\"tipo\").asText(\"\"), out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "02cf9608a1809fcca9a4aa718506a2df5187bfa3": {"t": 1785752814.4040654, "n": 59657, "h": "f344f40303c64566", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private JarvisContextoFinanceiroService jarvisContextoFinanceiroService;\n\n private RestTemplate restTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null || jarvisContextoFinanceiroService == null) {\n return \"\";\n }\n try {\n return jarvisContextoFinanceiroService.montarBlocoContexto(userId);\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|CREATE_FIXED_EXPENSE|CREATE_SUBSCRIPTION|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|LIST_ACCOUNTS|TRANSFER_BETWEEN_ACCOUNTS|LIST_TRANSACTIONS|LIST_CATEGORIES|LIST_METAS|GET_REPORT_SUMMARY|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|SET_INCOME_PROFILE|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|LIST_SUBSCRIPTIONS|TOGGLE_SUBSCRIPTION|START_TUTORIAL|STOP_TUTORIAL|TUTORIAL_STEP|UNKNOWN), \" +\n \"contaOrigem (apelido/nome da conta de origem na transferência), contaDestino (apelido/nome da conta de destino na transferência), \" +\n \"mes (1-12, opcional — consultas de extrato/orçamento), ano (ex.: 2026, opcional), tipo (DESPESA|RECEITA, opcional — filtro de extrato), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento|despesa_fixa|assinatura), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"subscriptionActive (true para ativar/reativar assinatura cadastrada; false para desativar/pausar), \" +\n \"tipoPerfil (CONTRACHEQUE|RECEBIMENTO_UNICO|FLUXO_DIARIO — perfil híbrido de renda), \" +\n \"salarioBruto, descontosHolerite (total ou use descontosFixos em updates), diaRecebimento (1-31, CONTRACHEQUE), \" +\n \"valorLiquidoFixo, diaRecebimentoFixo (RECEBIMENTO_UNICO), metaFaturamentoMensal (FLUXO_DIARIO), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"nome_normalizado (nome corrigido da entidade identificada), entidade_ambigua (true se abreviação dupla), \" +\n \"entidade_desconhecida (true se não reconhecer), opcoes_entidade (array de nomes quando ambíguo), \" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount; categoryName quando o estabelecimento \" +\n \"implicar categoria (ex.: ubr/uber→Transporte, mrcdo/mercado→Alimentação); nome_normalizado nas entidades.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor, dia_vencimento (1-31).\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se pedir saldos das contas, patrimônio em contas ou quanto tem nas contas (ex.: 'quanto eu tenho nas contas?', \" +\n \"'saldos das contas', 'meu patrimônio', 'lista minhas contas', 'saldo do nu', 'saldo da conta Nubank'): \" +\n \"action LIST_ACCOUNTS; preencher accountName ou bank com o apelido citado; nome_normalizado com o nome corrigido.\\n\" +\n \"- Se pedir transferir/mover/passar valor entre contas bancárias (ex.: 'transfere 100 do Itaú pro Nubank', \" +\n \"'passa 50 reais da conta Inter para a poupança', 'manda 200 da corrente pra carteira'): action TRANSFER_BETWEEN_ACCOUNTS; \" +\n \"preencher amount (valor), contaOrigem e contaDestino com os apelidos citados; NÃO use CREATE_EXPENSE para transferências internas.\\n\" +\n \"- Se pedir extrato, últimos gastos/receitas ou listar transações (ex.: 'quais foram meus últimos gastos?', \" +\n \"'lista os pix que recebi em junho', 'o que gastei na categoria mercado esse mês?'): action LIST_TRANSACTIONS; \" +\n \"preencher mes/ano ou reportMonth/reportYear quando citar período; tipo=DESPESA ou RECEITA se filtrar; categoryName/categoria se citar categoria.\\n\" +\n \"- Se pedir listar categorias cadastradas (ex.: 'quais categorias eu tenho?', 'lista minhas categorias'): action LIST_CATEGORIES.\\n\" +\n \"- Se pedir estado das metas/objetivos (ex.: 'como estão minhas metas?', 'quais são os meus objetivos de economia?'): action LIST_METAS.\\n\" +\n \"- Se pedir resumo de orçamento, limites por categoria ou gasto vs limite (ex.: 'quanto gastei do meu orçamento?', \" +\n \"'resumo de gastos do orçamento', 'meus limites de categoria'): action GET_REPORT_SUMMARY; mes/ano se citar mês.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação', \" +\n \"'apague despesa fixa aluguel', 'remova assinatura Netflix'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento | despesa_fixa | assinatura; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se configurar *perfil de renda híbrido* (contracheque, recebimento único ou fluxo diário), ex.: \" +\n \"'minha renda agora é fluxo diário com meta de 5000', 'recebo um pix único de 4000 todo dia 10', \" +\n \"'meu salário bruto é 6000 com descontos de 800 dia 5': action SET_INCOME_PROFILE; \" +\n \"tipoPerfil = CONTRACHEQUE | RECEBIMENTO_UNICO | FLUXO_DIARIO; \" +\n \"CONTRACHEQUE: salarioBruto, descontosHolerite (total) ou descontosFixos em updates, diaRecebimento; \" +\n \"RECEBIMENTO_UNICO: valorLiquidoFixo, diaRecebimentoFixo; FLUXO_DIARIO: metaFaturamentoMensal (opcional). \" +\n \"Prefira SET_INCOME_PROFILE quando a frase citar explicitamente o *tipo* de trabalhador/perfil.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Listar assinaturas cadastradas/monitoradas (ex.: 'quais minhas assinaturas?', 'lista assinaturas'): action LIST_SUBSCRIPTIONS.\\n\" +\n \"- Cadastrar assinatura recorrente manualmente (ex.: 'assinei o plano do Spotify por 24,90 por mês', \" +\n \"'cadastra a assinatura da Netflix de 55,90 todo dia 15'): action CREATE_SUBSCRIPTION; description = nome; amount = valor mensal; dueDay = dia de cobrança (1-31).\\n\" +\n \"- Cadastrar despesa fixa/obrigação mensal (ex.: 'cadastra a despesa fixa de aluguel valor 1200 vencimento todo dia 10', \" +\n \"'salve essa despesa fixa de 250 para internet dia 10'): action CREATE_FIXED_EXPENSE; description = nome; amount = valor; dueDay = dia de vencimento.\\n\" +\n \"- Ativar ou desativar assinatura cadastrada (ex.: 'desative a assinatura da Netflix', 'pause a academia', 'reative o Spotify'): \" +\n \"action TOGGLE_SUBSCRIPTION; description ou searchPhrase = nome da assinatura; subscriptionActive=false para desativar/pausar, true para ativar.\\n\" +\n \"- Tutorial interativo opt-in (guia de uso do sistema): action START_TUTORIAL para frases como \" +\n \"'tutorial', 'como usar', 'me ensina a usar o sistema', 'como funciona isso aqui', 'ajuda', 'help', 'nao sei usar'.\\n\" +\n \"- START_TUTORIAL também quando o utilizador perguntar sobre uma aba/tela específica — preencha reportMonth com o capítulo (1-5) e entregue o capítulo direto, sem menu:\\n\" +\n \" 'como uso a aba de contas?' → reportMonth=1 · 'me explica a tela de renda' → reportMonth=4 · \" +\n \"'o que e a aba de metas?' → reportMonth=5 · 'nao entendo a aba de cartoes' → reportMonth=3 · \" +\n \"'como funciona a aba de transacoes?' → reportMonth=2.\\n\" +\n \"- Encerrar tutorial ativo: action STOP_TUTORIAL para 'sair', 'parar', 'desligar', 'cancelar', 'encerrar tutorial', 'chega', 'voltar ao normal'.\\n\" +\n \"- Navegar capítulo do tutorial (1 a 5) quando o utilizador está no guia: action TUTORIAL_STEP; preencher reportMonth com o número do capítulo (1-5) se útil.\\n\" +\n \"REGRAS DE TOLERÂNCIA A LINGUAGEM:\\n\" +\n \"Ao interpretar a mensagem do usuário, aplique normalizações ANTES de classificar a intenção.\\n\" +\n \"ABREVIAÇÕES FINANCEIRAS: nu/nub/nuba/nubank→Nubank; ita/itau/itá→Itaú; bb/bradesco→Bradesco; inter→Banco Inter; \" +\n \"xp/xpi→XP; cef/caixa/cx→Caixa; sntdr/santander→Santander.\\n\" +\n \"ESTABELECIMENTOS: mrcdo/mercd/mkt/feira/sacolão→Mercado/Alimentação; ubr/uber/99→Transporte; \" +\n \"ifood/ifd/rappi→Alimentação/Delivery; farm/farmci/drog→Farmácia; posto/gasolina/combust→Combustível; \" +\n \"academia/acad/gym→Academia; netf/netflix/spotify/prime→Assinaturas/Lazer.\\n\" +\n \"INFORMAIS: tô no vermelho/zerado/liso→saldo crítico; sobrou/sobra→consulta saldo; fechei/como tá o mês→relatório; \" +\n \"queimei/estourei→orçamento excedido; guardei/separei→poupança/meta; parcelei/em x vezes→parcelado.\\n\" +\n \"REGRAS: (1) corrija erros óbvios pelo contexto; (2) abreviação dupla→entidade_ambigua+opcoes_entidade; \" +\n \"(3) não invente entidade→entidade_desconhecida; (4) preencha nome_normalizado com o nome corrigido.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n /** Visão computacional para boleto bancário ou QR Code Pix (agendamento de pagamento). */\n public JsonNode analisarImagemBoletoPix(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para leitura de boleto/Pix\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você extrai dados de boleto bancário brasileiro ou QR Code Pix para agendamento de pagamento. \"\n + \"Retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise a imagem e extraia: \"\n + \"tipo (BOLETO|PIX|CUPOM_FISCAL|OUTRO), beneficiario (string), valor (número decimal), \"\n + \"dataVencimento (yyyy-MM-dd), linhaDigitavel ou codigoBarrasOuPix (se visível), \"\n + \"confianca (0 a 1), erro (string opcional). \"\n + \"Use tipo CUPOM_FISCAL se for nota fiscal de compra, não boleto. \"\n + \"Se não for possível ler com segurança, preencha erro e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId, systemPrompt, userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, dataUrl);\n },\n \"Falha leitura boleto/Pix em todos provedores: \"\n );\n log.info(\"[VISION-LOG] Boleto/Pix userId={} tipo={} confianca={}\",\n userId, out.path(\"tipo\").asText(\"\"), out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "a72ef57f1b80e2764d67f14369cf61489cef6611": {"t": 1785752815.8644423, "n": 57938, "h": "bc990b0ec389225f", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private JarvisContextoFinanceiroService jarvisContextoFinanceiroService;\n\n private RestTemplate restTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null || jarvisContextoFinanceiroService == null) {\n return \"\";\n }\n try {\n return jarvisContextoFinanceiroService.montarBlocoContexto(userId);\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|CREATE_FIXED_EXPENSE|CREATE_SUBSCRIPTION|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|LIST_ACCOUNTS|TRANSFER_BETWEEN_ACCOUNTS|LIST_TRANSACTIONS|LIST_CATEGORIES|LIST_METAS|GET_REPORT_SUMMARY|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|SET_INCOME_PROFILE|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|LIST_SUBSCRIPTIONS|TOGGLE_SUBSCRIPTION|START_TUTORIAL|STOP_TUTORIAL|TUTORIAL_STEP|UNKNOWN), \" +\n \"contaOrigem (apelido/nome da conta de origem na transferência), contaDestino (apelido/nome da conta de destino na transferência), \" +\n \"mes (1-12, opcional — consultas de extrato/orçamento), ano (ex.: 2026, opcional), tipo (DESPESA|RECEITA, opcional — filtro de extrato), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento|despesa_fixa|assinatura), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"subscriptionActive (true para ativar/reativar assinatura cadastrada; false para desativar/pausar), \" +\n \"tipoPerfil (CONTRACHEQUE|RECEBIMENTO_UNICO|FLUXO_DIARIO — perfil híbrido de renda), \" +\n \"salarioBruto, descontosHolerite (total ou use descontosFixos em updates), diaRecebimento (1-31, CONTRACHEQUE), \" +\n \"valorLiquidoFixo, diaRecebimentoFixo (RECEBIMENTO_UNICO), metaFaturamentoMensal (FLUXO_DIARIO), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor, dia_vencimento (1-31).\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se pedir saldos das contas, patrimônio em contas ou quanto tem nas contas (ex.: 'quanto eu tenho nas contas?', \" +\n \"'saldos das contas', 'meu patrimônio', 'lista minhas contas'): action LIST_ACCOUNTS.\\n\" +\n \"- Se pedir transferir/mover/passar valor entre contas bancárias (ex.: 'transfere 100 do Itaú pro Nubank', \" +\n \"'passa 50 reais da conta Inter para a poupança', 'manda 200 da corrente pra carteira'): action TRANSFER_BETWEEN_ACCOUNTS; \" +\n \"preencher amount (valor), contaOrigem e contaDestino com os apelidos citados; NÃO use CREATE_EXPENSE para transferências internas.\\n\" +\n \"- Se pedir extrato, últimos gastos/receitas ou listar transações (ex.: 'quais foram meus últimos gastos?', \" +\n \"'lista os pix que recebi em junho', 'o que gastei na categoria mercado esse mês?'): action LIST_TRANSACTIONS; \" +\n \"preencher mes/ano ou reportMonth/reportYear quando citar período; tipo=DESPESA ou RECEITA se filtrar; categoryName/categoria se citar categoria.\\n\" +\n \"- Se pedir listar categorias cadastradas (ex.: 'quais categorias eu tenho?', 'lista minhas categorias'): action LIST_CATEGORIES.\\n\" +\n \"- Se pedir estado das metas/objetivos (ex.: 'como estão minhas metas?', 'quais são os meus objetivos de economia?'): action LIST_METAS.\\n\" +\n \"- Se pedir resumo de orçamento, limites por categoria ou gasto vs limite (ex.: 'quanto gastei do meu orçamento?', \" +\n \"'resumo de gastos do orçamento', 'meus limites de categoria'): action GET_REPORT_SUMMARY; mes/ano se citar mês.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação', \" +\n \"'apague despesa fixa aluguel', 'remova assinatura Netflix'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento | despesa_fixa | assinatura; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se configurar *perfil de renda híbrido* (contracheque, recebimento único ou fluxo diário), ex.: \" +\n \"'minha renda agora é fluxo diário com meta de 5000', 'recebo um pix único de 4000 todo dia 10', \" +\n \"'meu salário bruto é 6000 com descontos de 800 dia 5': action SET_INCOME_PROFILE; \" +\n \"tipoPerfil = CONTRACHEQUE | RECEBIMENTO_UNICO | FLUXO_DIARIO; \" +\n \"CONTRACHEQUE: salarioBruto, descontosHolerite (total) ou descontosFixos em updates, diaRecebimento; \" +\n \"RECEBIMENTO_UNICO: valorLiquidoFixo, diaRecebimentoFixo; FLUXO_DIARIO: metaFaturamentoMensal (opcional). \" +\n \"Prefira SET_INCOME_PROFILE quando a frase citar explicitamente o *tipo* de trabalhador/perfil.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Listar assinaturas cadastradas/monitoradas (ex.: 'quais minhas assinaturas?', 'lista assinaturas'): action LIST_SUBSCRIPTIONS.\\n\" +\n \"- Cadastrar assinatura recorrente manualmente (ex.: 'assinei o plano do Spotify por 24,90 por mês', \" +\n \"'cadastra a assinatura da Netflix de 55,90 todo dia 15'): action CREATE_SUBSCRIPTION; description = nome; amount = valor mensal; dueDay = dia de cobrança (1-31).\\n\" +\n \"- Cadastrar despesa fixa/obrigação mensal (ex.: 'cadastra a despesa fixa de aluguel valor 1200 vencimento todo dia 10', \" +\n \"'salve essa despesa fixa de 250 para internet dia 10'): action CREATE_FIXED_EXPENSE; description = nome; amount = valor; dueDay = dia de vencimento.\\n\" +\n \"- Ativar ou desativar assinatura cadastrada (ex.: 'desative a assinatura da Netflix', 'pause a academia', 'reative o Spotify'): \" +\n \"action TOGGLE_SUBSCRIPTION; description ou searchPhrase = nome da assinatura; subscriptionActive=false para desativar/pausar, true para ativar.\\n\" +\n \"- Tutorial interativo opt-in (guia de uso do sistema): action START_TUTORIAL para frases como \" +\n \"'tutorial', 'como usar', 'me ensina a usar o sistema', 'como funciona isso aqui', 'ajuda', 'help', 'nao sei usar'.\\n\" +\n \"- START_TUTORIAL também quando o utilizador perguntar sobre uma aba/tela específica — preencha reportMonth com o capítulo (1-5) e entregue o capítulo direto, sem menu:\\n\" +\n \" 'como uso a aba de contas?' → reportMonth=1 · 'me explica a tela de renda' → reportMonth=4 · \" +\n \"'o que e a aba de metas?' → reportMonth=5 · 'nao entendo a aba de cartoes' → reportMonth=3 · \" +\n \"'como funciona a aba de transacoes?' → reportMonth=2.\\n\" +\n \"- Encerrar tutorial ativo: action STOP_TUTORIAL para 'sair', 'parar', 'desligar', 'cancelar', 'encerrar tutorial', 'chega', 'voltar ao normal'.\\n\" +\n \"- Navegar capítulo do tutorial (1 a 5) quando o utilizador está no guia: action TUTORIAL_STEP; preencher reportMonth com o número do capítulo (1-5) se útil.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n /** Visão computacional para boleto bancário ou QR Code Pix (agendamento de pagamento). */\n public JsonNode analisarImagemBoletoPix(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para leitura de boleto/Pix\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você extrai dados de boleto bancário brasileiro ou QR Code Pix para agendamento de pagamento. \"\n + \"Retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise a imagem e extraia: \"\n + \"tipo (BOLETO|PIX|CUPOM_FISCAL|OUTRO), beneficiario (string), valor (número decimal), \"\n + \"dataVencimento (yyyy-MM-dd), linhaDigitavel ou codigoBarrasOuPix (se visível), \"\n + \"confianca (0 a 1), erro (string opcional). \"\n + \"Use tipo CUPOM_FISCAL se for nota fiscal de compra, não boleto. \"\n + \"Se não for possível ler com segurança, preencha erro e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId, systemPrompt, userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, dataUrl);\n },\n \"Falha leitura boleto/Pix em todos provedores: \"\n );\n log.info(\"[VISION-LOG] Boleto/Pix userId={} tipo={} confianca={}\",\n userId, out.path(\"tipo\").asText(\"\"), out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "c48a584c0a6042437cc7d7c429b5e1d588217201": {"t": 1785752817.4452152, "n": 57455, "h": "77eb3d2d74dca211", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private JarvisContextoFinanceiroService jarvisContextoFinanceiroService;\n\n private RestTemplate restTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null || jarvisContextoFinanceiroService == null) {\n return \"\";\n }\n try {\n return jarvisContextoFinanceiroService.montarBlocoContexto(userId);\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|CREATE_FIXED_EXPENSE|CREATE_SUBSCRIPTION|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|LIST_ACCOUNTS|TRANSFER_BETWEEN_ACCOUNTS|LIST_TRANSACTIONS|LIST_CATEGORIES|LIST_METAS|GET_REPORT_SUMMARY|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|SET_INCOME_PROFILE|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|LIST_SUBSCRIPTIONS|TOGGLE_SUBSCRIPTION|START_TUTORIAL|STOP_TUTORIAL|TUTORIAL_STEP|UNKNOWN), \" +\n \"contaOrigem (apelido/nome da conta de origem na transferência), contaDestino (apelido/nome da conta de destino na transferência), \" +\n \"mes (1-12, opcional — consultas de extrato/orçamento), ano (ex.: 2026, opcional), tipo (DESPESA|RECEITA, opcional — filtro de extrato), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento|despesa_fixa|assinatura), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"subscriptionActive (true para ativar/reativar assinatura cadastrada; false para desativar/pausar), \" +\n \"tipoPerfil (CONTRACHEQUE|RECEBIMENTO_UNICO|FLUXO_DIARIO — perfil híbrido de renda), \" +\n \"salarioBruto, descontosHolerite (total ou use descontosFixos em updates), diaRecebimento (1-31, CONTRACHEQUE), \" +\n \"valorLiquidoFixo, diaRecebimentoFixo (RECEBIMENTO_UNICO), metaFaturamentoMensal (FLUXO_DIARIO), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor, dia_vencimento (1-31).\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se pedir saldos das contas, patrimônio em contas ou quanto tem nas contas (ex.: 'quanto eu tenho nas contas?', \" +\n \"'saldos das contas', 'meu patrimônio', 'lista minhas contas'): action LIST_ACCOUNTS.\\n\" +\n \"- Se pedir transferir/mover/passar valor entre contas bancárias (ex.: 'transfere 100 do Itaú pro Nubank', \" +\n \"'passa 50 reais da conta Inter para a poupança', 'manda 200 da corrente pra carteira'): action TRANSFER_BETWEEN_ACCOUNTS; \" +\n \"preencher amount (valor), contaOrigem e contaDestino com os apelidos citados; NÃO use CREATE_EXPENSE para transferências internas.\\n\" +\n \"- Se pedir extrato, últimos gastos/receitas ou listar transações (ex.: 'quais foram meus últimos gastos?', \" +\n \"'lista os pix que recebi em junho', 'o que gastei na categoria mercado esse mês?'): action LIST_TRANSACTIONS; \" +\n \"preencher mes/ano ou reportMonth/reportYear quando citar período; tipo=DESPESA ou RECEITA se filtrar; categoryName/categoria se citar categoria.\\n\" +\n \"- Se pedir listar categorias cadastradas (ex.: 'quais categorias eu tenho?', 'lista minhas categorias'): action LIST_CATEGORIES.\\n\" +\n \"- Se pedir estado das metas/objetivos (ex.: 'como estão minhas metas?', 'quais são os meus objetivos de economia?'): action LIST_METAS.\\n\" +\n \"- Se pedir resumo de orçamento, limites por categoria ou gasto vs limite (ex.: 'quanto gastei do meu orçamento?', \" +\n \"'resumo de gastos do orçamento', 'meus limites de categoria'): action GET_REPORT_SUMMARY; mes/ano se citar mês.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação', \" +\n \"'apague despesa fixa aluguel', 'remova assinatura Netflix'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento | despesa_fixa | assinatura; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se configurar *perfil de renda híbrido* (contracheque, recebimento único ou fluxo diário), ex.: \" +\n \"'minha renda agora é fluxo diário com meta de 5000', 'recebo um pix único de 4000 todo dia 10', \" +\n \"'meu salário bruto é 6000 com descontos de 800 dia 5': action SET_INCOME_PROFILE; \" +\n \"tipoPerfil = CONTRACHEQUE | RECEBIMENTO_UNICO | FLUXO_DIARIO; \" +\n \"CONTRACHEQUE: salarioBruto, descontosHolerite (total) ou descontosFixos em updates, diaRecebimento; \" +\n \"RECEBIMENTO_UNICO: valorLiquidoFixo, diaRecebimentoFixo; FLUXO_DIARIO: metaFaturamentoMensal (opcional). \" +\n \"Prefira SET_INCOME_PROFILE quando a frase citar explicitamente o *tipo* de trabalhador/perfil.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Listar assinaturas cadastradas/monitoradas (ex.: 'quais minhas assinaturas?', 'lista assinaturas'): action LIST_SUBSCRIPTIONS.\\n\" +\n \"- Cadastrar assinatura recorrente manualmente (ex.: 'assinei o plano do Spotify por 24,90 por mês', \" +\n \"'cadastra a assinatura da Netflix de 55,90 todo dia 15'): action CREATE_SUBSCRIPTION; description = nome; amount = valor mensal; dueDay = dia de cobrança (1-31).\\n\" +\n \"- Cadastrar despesa fixa/obrigação mensal (ex.: 'cadastra a despesa fixa de aluguel valor 1200 vencimento todo dia 10', \" +\n \"'salve essa despesa fixa de 250 para internet dia 10'): action CREATE_FIXED_EXPENSE; description = nome; amount = valor; dueDay = dia de vencimento.\\n\" +\n \"- Ativar ou desativar assinatura cadastrada (ex.: 'desative a assinatura da Netflix', 'pause a academia', 'reative o Spotify'): \" +\n \"action TOGGLE_SUBSCRIPTION; description ou searchPhrase = nome da assinatura; subscriptionActive=false para desativar/pausar, true para ativar.\\n\" +\n \"- Tutorial interativo opt-in (guia de uso do sistema): action START_TUTORIAL para frases como \" +\n \"'tutorial', 'como usar', 'me ensina', 'como funciona', 'guia de uso', 'me explica o sistema', 'ajuda tutorial'.\\n\" +\n \"- Encerrar tutorial ativo: action STOP_TUTORIAL para 'sair', 'parar', 'desligar', 'cancelar', 'encerrar tutorial', 'chega', 'voltar ao normal'.\\n\" +\n \"- Navegar capítulo do tutorial (1 a 5) quando o utilizador está no guia: action TUTORIAL_STEP; preencher reportMonth com o número do capítulo (1-5) se útil.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n /** Visão computacional para boleto bancário ou QR Code Pix (agendamento de pagamento). */\n public JsonNode analisarImagemBoletoPix(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para leitura de boleto/Pix\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você extrai dados de boleto bancário brasileiro ou QR Code Pix para agendamento de pagamento. \"\n + \"Retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise a imagem e extraia: \"\n + \"tipo (BOLETO|PIX|CUPOM_FISCAL|OUTRO), beneficiario (string), valor (número decimal), \"\n + \"dataVencimento (yyyy-MM-dd), linhaDigitavel ou codigoBarrasOuPix (se visível), \"\n + \"confianca (0 a 1), erro (string opcional). \"\n + \"Use tipo CUPOM_FISCAL se for nota fiscal de compra, não boleto. \"\n + \"Se não for possível ler com segurança, preencha erro e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId, systemPrompt, userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, dataUrl);\n },\n \"Falha leitura boleto/Pix em todos provedores: \"\n );\n log.info(\"[VISION-LOG] Boleto/Pix userId={} tipo={} confianca={}\",\n userId, out.path(\"tipo\").asText(\"\"), out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "9c32189e31c52bacedac13229e7d0f136be20aba": {"t": 1785752819.2194183, "n": 56834, "h": "a65b5c2f7ed6ad13", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private JarvisContextoFinanceiroService jarvisContextoFinanceiroService;\n\n private RestTemplate restTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null || jarvisContextoFinanceiroService == null) {\n return \"\";\n }\n try {\n return jarvisContextoFinanceiroService.montarBlocoContexto(userId);\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|CREATE_FIXED_EXPENSE|CREATE_SUBSCRIPTION|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|LIST_ACCOUNTS|TRANSFER_BETWEEN_ACCOUNTS|LIST_TRANSACTIONS|LIST_CATEGORIES|LIST_METAS|GET_REPORT_SUMMARY|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|SET_INCOME_PROFILE|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|LIST_SUBSCRIPTIONS|TOGGLE_SUBSCRIPTION|UNKNOWN), \" +\n \"contaOrigem (apelido/nome da conta de origem na transferência), contaDestino (apelido/nome da conta de destino na transferência), \" +\n \"mes (1-12, opcional — consultas de extrato/orçamento), ano (ex.: 2026, opcional), tipo (DESPESA|RECEITA, opcional — filtro de extrato), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento|despesa_fixa|assinatura), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"subscriptionActive (true para ativar/reativar assinatura cadastrada; false para desativar/pausar), \" +\n \"tipoPerfil (CONTRACHEQUE|RECEBIMENTO_UNICO|FLUXO_DIARIO — perfil híbrido de renda), \" +\n \"salarioBruto, descontosHolerite (total ou use descontosFixos em updates), diaRecebimento (1-31, CONTRACHEQUE), \" +\n \"valorLiquidoFixo, diaRecebimentoFixo (RECEBIMENTO_UNICO), metaFaturamentoMensal (FLUXO_DIARIO), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor, dia_vencimento (1-31).\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se pedir saldos das contas, patrimônio em contas ou quanto tem nas contas (ex.: 'quanto eu tenho nas contas?', \" +\n \"'saldos das contas', 'meu patrimônio', 'lista minhas contas'): action LIST_ACCOUNTS.\\n\" +\n \"- Se pedir transferir/mover/passar valor entre contas bancárias (ex.: 'transfere 100 do Itaú pro Nubank', \" +\n \"'passa 50 reais da conta Inter para a poupança', 'manda 200 da corrente pra carteira'): action TRANSFER_BETWEEN_ACCOUNTS; \" +\n \"preencher amount (valor), contaOrigem e contaDestino com os apelidos citados; NÃO use CREATE_EXPENSE para transferências internas.\\n\" +\n \"- Se pedir extrato, últimos gastos/receitas ou listar transações (ex.: 'quais foram meus últimos gastos?', \" +\n \"'lista os pix que recebi em junho', 'o que gastei na categoria mercado esse mês?'): action LIST_TRANSACTIONS; \" +\n \"preencher mes/ano ou reportMonth/reportYear quando citar período; tipo=DESPESA ou RECEITA se filtrar; categoryName/categoria se citar categoria.\\n\" +\n \"- Se pedir listar categorias cadastradas (ex.: 'quais categorias eu tenho?', 'lista minhas categorias'): action LIST_CATEGORIES.\\n\" +\n \"- Se pedir estado das metas/objetivos (ex.: 'como estão minhas metas?', 'quais são os meus objetivos de economia?'): action LIST_METAS.\\n\" +\n \"- Se pedir resumo de orçamento, limites por categoria ou gasto vs limite (ex.: 'quanto gastei do meu orçamento?', \" +\n \"'resumo de gastos do orçamento', 'meus limites de categoria'): action GET_REPORT_SUMMARY; mes/ano se citar mês.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação', \" +\n \"'apague despesa fixa aluguel', 'remova assinatura Netflix'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento | despesa_fixa | assinatura; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se configurar *perfil de renda híbrido* (contracheque, recebimento único ou fluxo diário), ex.: \" +\n \"'minha renda agora é fluxo diário com meta de 5000', 'recebo um pix único de 4000 todo dia 10', \" +\n \"'meu salário bruto é 6000 com descontos de 800 dia 5': action SET_INCOME_PROFILE; \" +\n \"tipoPerfil = CONTRACHEQUE | RECEBIMENTO_UNICO | FLUXO_DIARIO; \" +\n \"CONTRACHEQUE: salarioBruto, descontosHolerite (total) ou descontosFixos em updates, diaRecebimento; \" +\n \"RECEBIMENTO_UNICO: valorLiquidoFixo, diaRecebimentoFixo; FLUXO_DIARIO: metaFaturamentoMensal (opcional). \" +\n \"Prefira SET_INCOME_PROFILE quando a frase citar explicitamente o *tipo* de trabalhador/perfil.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Listar assinaturas cadastradas/monitoradas (ex.: 'quais minhas assinaturas?', 'lista assinaturas'): action LIST_SUBSCRIPTIONS.\\n\" +\n \"- Cadastrar assinatura recorrente manualmente (ex.: 'assinei o plano do Spotify por 24,90 por mês', \" +\n \"'cadastra a assinatura da Netflix de 55,90 todo dia 15'): action CREATE_SUBSCRIPTION; description = nome; amount = valor mensal; dueDay = dia de cobrança (1-31).\\n\" +\n \"- Cadastrar despesa fixa/obrigação mensal (ex.: 'cadastra a despesa fixa de aluguel valor 1200 vencimento todo dia 10', \" +\n \"'salve essa despesa fixa de 250 para internet dia 10'): action CREATE_FIXED_EXPENSE; description = nome; amount = valor; dueDay = dia de vencimento.\\n\" +\n \"- Ativar ou desativar assinatura cadastrada (ex.: 'desative a assinatura da Netflix', 'pause a academia', 'reative o Spotify'): \" +\n \"action TOGGLE_SUBSCRIPTION; description ou searchPhrase = nome da assinatura; subscriptionActive=false para desativar/pausar, true para ativar.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n /** Visão computacional para boleto bancário ou QR Code Pix (agendamento de pagamento). */\n public JsonNode analisarImagemBoletoPix(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para leitura de boleto/Pix\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você extrai dados de boleto bancário brasileiro ou QR Code Pix para agendamento de pagamento. \"\n + \"Retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise a imagem e extraia: \"\n + \"tipo (BOLETO|PIX|CUPOM_FISCAL|OUTRO), beneficiario (string), valor (número decimal), \"\n + \"dataVencimento (yyyy-MM-dd), linhaDigitavel ou codigoBarrasOuPix (se visível), \"\n + \"confianca (0 a 1), erro (string opcional). \"\n + \"Use tipo CUPOM_FISCAL se for nota fiscal de compra, não boleto. \"\n + \"Se não for possível ler com segurança, preencha erro e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId, systemPrompt, userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, dataUrl);\n },\n \"Falha leitura boleto/Pix em todos provedores: \"\n );\n log.info(\"[VISION-LOG] Boleto/Pix userId={} tipo={} confianca={}\",\n userId, out.path(\"tipo\").asText(\"\"), out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "55cd0f7840daef2e8946888edabdfaae2f0848e4": {"t": 1785752826.624646, "n": 52897, "h": "f46cbce427c838d1", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private JarvisContextoFinanceiroService jarvisContextoFinanceiroService;\n\n private RestTemplate restTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null || jarvisContextoFinanceiroService == null) {\n return \"\";\n }\n try {\n return jarvisContextoFinanceiroService.montarBlocoContexto(userId);\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|LIST_SUBSCRIPTIONS|TOGGLE_SUBSCRIPTION|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"subscriptionActive (true para ativar/reativar assinatura cadastrada; false para desativar/pausar), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Listar assinaturas cadastradas/monitoradas (ex.: 'quais minhas assinaturas?', 'lista assinaturas'): action LIST_SUBSCRIPTIONS.\\n\" +\n \"- Ativar ou desativar assinatura cadastrada (ex.: 'desative a assinatura da Netflix', 'pause a academia', 'reative o Spotify'): \" +\n \"action TOGGLE_SUBSCRIPTION; description ou searchPhrase = nome da assinatura; subscriptionActive=false para desativar/pausar, true para ativar.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n /** Visão computacional para boleto bancário ou QR Code Pix (agendamento de pagamento). */\n public JsonNode analisarImagemBoletoPix(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para leitura de boleto/Pix\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você extrai dados de boleto bancário brasileiro ou QR Code Pix para agendamento de pagamento. \"\n + \"Retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise a imagem e extraia: \"\n + \"tipo (BOLETO|PIX|CUPOM_FISCAL|OUTRO), beneficiario (string), valor (número decimal), \"\n + \"dataVencimento (yyyy-MM-dd), linhaDigitavel ou codigoBarrasOuPix (se visível), \"\n + \"confianca (0 a 1), erro (string opcional). \"\n + \"Use tipo CUPOM_FISCAL se for nota fiscal de compra, não boleto. \"\n + \"Se não for possível ler com segurança, preencha erro e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId, systemPrompt, userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, dataUrl);\n },\n \"Falha leitura boleto/Pix em todos provedores: \"\n );\n log.info(\"[VISION-LOG] Boleto/Pix userId={} tipo={} confianca={}\",\n userId, out.path(\"tipo\").asText(\"\"), out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "09900b85c21b84945bfab1148f120480e3e1a3b7": {"t": 1785752828.1256444, "n": 52288, "h": "436223876258394a", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private JarvisContextoFinanceiroService jarvisContextoFinanceiroService;\n\n private RestTemplate restTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null || jarvisContextoFinanceiroService == null) {\n return \"\";\n }\n try {\n return jarvisContextoFinanceiroService.montarBlocoContexto(userId);\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n /** Visão computacional para boleto bancário ou QR Code Pix (agendamento de pagamento). */\n public JsonNode analisarImagemBoletoPix(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para leitura de boleto/Pix\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você extrai dados de boleto bancário brasileiro ou QR Code Pix para agendamento de pagamento. \"\n + \"Retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise a imagem e extraia: \"\n + \"tipo (BOLETO|PIX|CUPOM_FISCAL|OUTRO), beneficiario (string), valor (número decimal), \"\n + \"dataVencimento (yyyy-MM-dd), linhaDigitavel ou codigoBarrasOuPix (se visível), \"\n + \"confianca (0 a 1), erro (string opcional). \"\n + \"Use tipo CUPOM_FISCAL se for nota fiscal de compra, não boleto. \"\n + \"Se não for possível ler com segurança, preencha erro e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId, systemPrompt, userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, dataUrl);\n },\n \"Falha leitura boleto/Pix em todos provedores: \"\n );\n log.info(\"[VISION-LOG] Boleto/Pix userId={} tipo={} confianca={}\",\n userId, out.path(\"tipo\").asText(\"\"), out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "27b129ec93ec1f1241112f83bd15393c88b78fff": {"t": 1785752830.4081378, "n": 49912, "h": "0f9aaeb3c3c4869c", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private JarvisContextoFinanceiroService jarvisContextoFinanceiroService;\n\n private RestTemplate restTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null || jarvisContextoFinanceiroService == null) {\n return \"\";\n }\n try {\n return jarvisContextoFinanceiroService.montarBlocoContexto(userId);\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|SPLIT_BILL|LIST_DEBTS|SETTLE_DEBT|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"splitMembers (array de nomes/apelidos dos membros marcados no racha-contas, ex.: [\\\"Esposa\\\",\\\"Filho\\\"]), \" +\n \"counterpartyAlias (nome/apelido do membro ao quitar débito, ex.: \\\"Esposa\\\"), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Racha-contas / dividir despesa no grupo familiar (ex.: 'racha os 150 do restaurante entre eu, a Esposa e o Filho', \" +\n \"'divide a conta de 90 comigo e com o João', 'racha 60 com a Maria'): action SPLIT_BILL; amount = valor total; \" +\n \"description = motivo/local (ex.: restaurante); splitMembers = array com os nomes dos OUTROS membros marcados \" +\n \"(NÃO inclua o próprio usuário/'eu'/'mim'). Ex.: 'racha 150 entre eu, Esposa e Filho' → amount=150, splitMembers=[\\\"Esposa\\\",\\\"Filho\\\"].\\n\" +\n \"- Consultar quem está devendo / pendências do grupo (ex.: 'quem está me devendo?', 'quais minhas pendências no grupo?', \" +\n \"'meu balanço da família'): action LIST_DEBTS.\\n\" +\n \"- Quitar/acertar débito interno do grupo (ex.: 'acertei os 50 com a Esposa', 'a Maria me pagou', 'quitei com o João'): \" +\n \"action SETTLE_DEBT; counterpartyAlias = nome do membro com quem acertou.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "5bb5070ca7b9f2e71638e0c1ca870593cbcf266b": {"t": 1785752832.7907133, "n": 48691, "h": "58ddcc704c5a73f0", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n\n /** Injeção lazy para quebrar o ciclo OpenAiService → Contexto → SaldoService → OpenAiService. */\n @org.springframework.beans.factory.annotation.Autowired\n @org.springframework.context.annotation.Lazy\n private JarvisContextoFinanceiroService jarvisContextoFinanceiroService;\n\n private RestTemplate restTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n /** Monta o bloco de contexto financeiro da persona; nunca lança nem deixa placeholder literal. */\n private String montarContextoFinanceiroSeguro(Long userId) {\n if (userId == null || jarvisContextoFinanceiroService == null) {\n return \"\";\n }\n try {\n return jarvisContextoFinanceiroService.montarBlocoContexto(userId);\n } catch (Exception e) {\n log.debug(\"Contexto financeiro J.A.R.V.I.S. indisponível userId={}: {}\", userId, e.getMessage());\n return \"\";\n }\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt, contextoFinanceiro);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String contextoFinanceiro = montarContextoFinanceiroSeguro(userId);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u, contextoFinanceiro);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "4a2b0198758f534e584c2a280a462a94426edc40": {"t": 1785752835.8404183, "n": 47659, "h": "ac7993cd33193720", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\n\nimport javax.annotation.PostConstruct;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n private RestTemplate restTemplate;\n\n @Value(\"${consumoesperto.ai.http.connect-timeout-ms:15000}\")\n private int aiHttpConnectTimeoutMs;\n\n @Value(\"${consumoesperto.ai.http.read-timeout-ms:300000}\")\n private int aiHttpReadTimeoutMs;\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n @PostConstruct\n void initAiRestTemplate() {\n SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n factory.setConnectTimeout(Math.max(5_000, aiHttpConnectTimeoutMs));\n factory.setReadTimeout(Math.max(30_000, aiHttpReadTimeoutMs));\n restTemplate = new RestTemplate(factory);\n }\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildChatJsonPayload(model, systemPrompt, userPrompt, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n boolean ollama = AiProviderType.OLLAMA.name().equalsIgnoreCase(providerName);\n Map<String, Object> payload = buildVisionJsonPayload(model, systemPrompt, userPrompt, imageSource, !ollama);\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n /** Ollama: sem {@code response_format} (mais estável); outros provedores usam JSON mode OpenAI. */\n private static Map<String, Object> buildChatJsonPayload(\n String model, String systemPrompt, String userPrompt, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n return payload;\n }\n\n private static Map<String, Object> buildVisionJsonPayload(\n String model, String systemPrompt, String userPrompt, String imageSource, boolean useOpenAiJsonMode\n ) {\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"temperature\", 0.1);\n if (useOpenAiJsonMode) {\n payload.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n payload.put(\n \"messages\",\n List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n return payload;\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = stripJsonFence(root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\"));\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "5e4c37992df38c3f381843f3ad9a4c29e753ab82": {"t": 1785752837.911448, "n": 45875, "h": "aece036a285034c4", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.ai.AiGatewayPromptContext;\nimport com.consumoesperto.service.ai.AiGatewayService;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\n\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final AiGatewayService aiGatewayService;\n private final RestTemplate restTemplate = new RestTemplate();\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n AiGatewayService.GatewayOptimizationResult visionOpt = aiGatewayService.optimizeBeforeProvider(\n userId,\n systemPrompt,\n userPrompt,\n AiGatewayPromptContext.fromPrompts(systemPrompt, userPrompt, false, true),\n resolveTargetModelForSuppressor(cfg, false)\n );\n final String visionSys = visionOpt.systemPrompt();\n final String visionUsr = visionOpt.userPrompt();\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(\n p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, visionSys, visionUsr, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** AI Gateway (ATS + estratégia AUTO) antes do fallback de providers. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n AiGatewayPromptContext ctx = AiGatewayPromptContext.builder()\n .systemPrompt(sys)\n .userPrompt(usr)\n .documentImport(documento)\n .jsonOutput(true)\n .build();\n AiGatewayService.GatewayOptimizationResult opt = aiGatewayService.optimizeBeforeProvider(\n userId, sys, usr, ctx, resolveTargetModelForSuppressor(cfg, documento));\n if (opt.optimizedByAts()) {\n log.info(\n \"[AiGateway] strategy={} saved~{} cache={}\",\n opt.strategyUsed(), opt.tokensSaved(), opt.fromCache());\n }\n return new PromptPar(opt.systemPrompt(), opt.userPrompt());\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "905cbed8d53b51d4536b6c24c268eeea67506bfb": {"t": 1785752839.469653, "n": 45149, "h": "bede491b9d4cbc39", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.util.AiProviderOrder;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\n\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final TokenSuppressorService tokenSuppressorService;\n private final RestTemplate restTemplate = new RestTemplate();\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n @Value(\"${consumoesperto.ai.platform-claude-api-key:}\")\n private String platformClaudeApiKey;\n\n @Value(\"${consumoesperto.ai.claude-base-url:https://api.anthropic.com}\")\n private String claudeBaseUrl;\n\n @Value(\"${consumoesperto.ai.claude-model:claude-3-5-haiku-20241022}\")\n private String claudeModel;\n\n @Value(\"${consumoesperto.ai.platform-deepseek-api-key:}\")\n private String platformDeepseekApiKey;\n\n @Value(\"${consumoesperto.ai.deepseek-base-url:https://api.deepseek.com/v1}\")\n private String deepseekBaseUrl;\n\n @Value(\"${consumoesperto.ai.deepseek-model:deepseek-chat}\")\n private String deepseekModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, false);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, otimizado.system(), otimizado.user());\n },\n \"Nao foi possivel processar IA (Groq/OpenAI/Claude/Gemini/DeepSeek/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, systemPrompt, userPrompt, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n PromptPar otimizado = aplicarSuppressorAntesDaIa(userId, systemPrompt, userPrompt, cfg, documento);\n final String systemFinal = otimizado.system();\n final String userFinal = otimizado.user();\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** Token Suppressor primeiro; depois fallback Groq → OpenAI → Claude → Gemini → DeepSeek → Ollama. */\n private PromptPar aplicarSuppressorAntesDaIa(\n Long userId, String systemPrompt, String userPrompt, AiProvidersConfig cfg, boolean documento\n ) {\n String sys = systemPrompt != null ? systemPrompt : \"\";\n String usr = userPrompt != null ? userPrompt : \"\";\n if (!tokenSuppressorService.isEnabled()) {\n return new PromptPar(sys, usr);\n }\n String targetModel = resolveTargetModelForSuppressor(cfg, documento);\n Optional<TokenSuppressorService.OptimizedPrompt> opt =\n tokenSuppressorService.tryOptimize(userId, sys, usr, targetModel);\n if (opt.isPresent()) {\n log.info(\"[TokenSuppressor] prompt otimizado antes da IA (saved~{} tokens)\", opt.get().tokensSaved());\n return new PromptPar(opt.get().systemPrompt(), opt.get().userPrompt());\n }\n return new PromptPar(sys, usr);\n }\n\n private record PromptPar(String system, String user) {}\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n for (AiProviderType p : AiProviderOrder.canonicalTypes()) {\n if (!canChatJson(cfg, p)) {\n continue;\n }\n if (p == AiProviderType.GROQ && documento) {\n return chatModelForDocument(p, cfg);\n }\n return chatModelFor(p, cfg);\n }\n return documento && groqModelDocument != null && !groqModelDocument.isBlank()\n ? groqModelDocument\n : \"llama-3.1-8b-instant\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n appendMissingPlatformProviders(cfg, errors);\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n return AiProviderOrder.canonicalTypes();\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE -> platformClaudeApiKey != null && !platformClaudeApiKey.isBlank() && hasUrl(claudeBaseUrl);\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case DEEPSEEK -> platformDeepseekApiKey != null && !platformDeepseekApiKey.isBlank() && hasUrl(deepseekBaseUrl);\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ, OPENAI, OLLAMA -> canChatJson(cfg, p);\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n };\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case CLAUDE, GEMINI, DEEPSEEK -> false;\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case OPENAI -> openai(cfg).getModel();\n case CLAUDE -> claudeModel != null && !claudeModel.isBlank() ? claudeModel : \"claude-3-5-haiku-20241022\";\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel != null && !deepseekModel.isBlank() ? deepseekModel : \"deepseek-chat\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case CLAUDE -> claudeModel;\n case GEMINI -> geminiModel;\n case DEEPSEEK -> deepseekModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case OPENAI -> openai(cfg).getWhisperModel();\n case CLAUDE, GEMINI, DEEPSEEK -> \"\";\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case OPENAI -> openai(cfg).getApiKey();\n case CLAUDE -> platformClaudeApiKey;\n case GEMINI -> platformGeminiApiKey;\n case DEEPSEEK -> platformDeepseekApiKey;\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case OPENAI -> openai(cfg).getBaseUrl();\n case CLAUDE -> claudeBaseUrl;\n case GEMINI -> geminiBaseUrl;\n case DEEPSEEK -> deepseekBaseUrl;\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private void appendMissingPlatformProviders(AiProvidersConfig cfg, List<String> errors) {\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.CLAUDE)) {\n errors.add(\"CLAUDE: CLAUDE_API_KEY não configurada no servidor\");\n }\n if (!canChatJson(cfg, AiProviderType.DEEPSEEK)) {\n errors.add(\"DEEPSEEK: DEEPSEEK_API_KEY não configurada no servidor\");\n }\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n return switch (provider) {\n case GEMINI -> parseGeminiJson(model, systemPrompt, userPrompt);\n case CLAUDE -> parseClaudeJson(model, systemPrompt, userPrompt);\n default -> parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n };\n }\n\n private JsonNode parseClaudeJson(String model, String systemPrompt, String userPrompt) {\n if (platformClaudeApiKey == null || platformClaudeApiKey.isBlank()) {\n throw new RuntimeException(\"CLAUDE_API_KEY não configurada\");\n }\n ensureBaseUrl(\"CLAUDE\", claudeBaseUrl);\n\n Map<String, Object> payload = new LinkedHashMap<>();\n payload.put(\"model\", model);\n payload.put(\"max_tokens\", 8192);\n payload.put(\"system\", systemPrompt);\n payload.put(\"messages\", List.of(Map.of(\"role\", \"user\", \"content\", userPrompt)));\n\n HttpHeaders headers = new HttpHeaders();\n headers.set(\"x-api-key\", platformClaudeApiKey.trim());\n headers.set(\"anthropic-version\", \"2023-06-01\");\n headers.setContentType(MediaType.APPLICATION_JSON);\n\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(claudeBaseUrl) + \"/v1/messages\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n try {\n JsonNode root = objectMapper.readTree(response.getBody());\n String content = root.path(\"content\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"CLAUDE retornou conteúdo vazio\");\n }\n log.info(\"IA processada via CLAUDE (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (RuntimeException e) {\n throw e;\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de CLAUDE\", e);\n }\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n cfg.setProviderOrder(AiProviderOrder.canonicalNamesCopy());\n return cfg;\n }\n}\n"}, "103e6233513a3b4c5c372c0ec1605950d25af6d7": {"t": 1785752841.0199955, "n": 41815, "h": "81ce7999251af166", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\n\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final TokenSuppressorService tokenSuppressorService;\n private final RestTemplate restTemplate = new RestTemplate();\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel processar IA (Groq/Gemini/OpenAI/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, systemPrompt, userPrompt, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String targetModel = resolveTargetModelForSuppressor(cfg, documento);\n String sys = systemPrompt;\n String usr = userPrompt;\n if (tokenSuppressorService.isEnabled()) {\n Optional<TokenSuppressorService.OptimizedPrompt> opt =\n tokenSuppressorService.tryOptimize(userId, sys, usr, targetModel);\n if (opt.isPresent()) {\n sys = opt.get().systemPrompt();\n usr = opt.get().userPrompt();\n }\n }\n final String systemFinal = sys;\n final String userFinal = usr;\n List<AiProviderType> order = documento ? orderedProvidersDocumentoFirst(cfg) : orderedProviders(cfg);\n return executeAIRequestWithFallback(\n cfg,\n order,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n /** PDF/fatura: tenta Gemini antes de Groq (evita gastar quota Groq quando Gemini está configurado). */\n private List<AiProviderType> orderedProvidersDocumentoFirst(AiProvidersConfig cfg) {\n List<AiProviderType> base = orderedProviders(cfg);\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n return base;\n }\n List<AiProviderType> out = new ArrayList<>();\n out.add(AiProviderType.GEMINI);\n for (AiProviderType p : base) {\n if (p != AiProviderType.GEMINI) {\n out.add(p);\n }\n }\n return out;\n }\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n if (platformGeminiApiKey != null && !platformGeminiApiKey.isBlank()) {\n return geminiModel != null && !geminiModel.isBlank() ? geminiModel : \"gemini-2.5-flash\";\n }\n GroqSection g = cfg.getGroq();\n if (g != null) {\n String m = documento ? groqModelDocument : g.getModelText();\n if (m != null && !m.isBlank()) {\n return m;\n }\n }\n OpenaiSection o = cfg.getOpenai();\n if (o != null && o.getModel() != null && !o.getModel().isBlank()) {\n return o.getModel();\n }\n return \"gemini-2.5-flash\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n return executeAIRequestWithFallback(cfg, orderedProviders(cfg), canUseProvider, attempt, failureMessagePrefix);\n }\n\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n List<AiProviderType> providerOrder,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : providerOrder) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n String geminiHint = canChatJson(cfg, AiProviderType.GEMINI)\n ? \"\"\n : \" GEMINI: chave ausente — defina GEMINI_API_KEY no .env do servidor.\";\n throw new RuntimeException(\n failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\" + geminiHint);\n }\n if (!canChatJson(cfg, AiProviderType.GEMINI)) {\n errors.add(\"GEMINI: GEMINI_API_KEY não configurada no servidor\");\n }\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n List<AiProviderType> out = new ArrayList<>();\n if (cfg.getProviderOrder() != null) {\n for (String raw : cfg.getProviderOrder()) {\n AiProviderType t = AiProviderType.fromString(raw);\n if (t != null && !out.contains(t)) {\n out.add(t);\n }\n }\n }\n for (AiProviderType t : AiProviderType.values()) {\n if (!out.contains(t)) {\n out.add(t);\n }\n }\n return out;\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return p != AiProviderType.GEMINI && canChatJson(cfg, p);\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> false;\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case GEMINI -> geminiModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getWhisperModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case GEMINI -> platformGeminiApiKey;\n case OPENAI -> openai(cfg).getApiKey();\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case GEMINI -> geminiBaseUrl;\n case OPENAI -> openai(cfg).getBaseUrl();\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n if (provider == AiProviderType.GEMINI) {\n return parseGeminiJson(model, systemPrompt, userPrompt);\n }\n return parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n aiProvidersConfigService.ensureGeminiInProviderOrder(cfg);\n return cfg;\n }\n}\n"}, "970ca34100ce59e517907f49ab95204dddb12a92": {"t": 1785752843.1090312, "n": 40352, "h": "e432f9333f301e6b", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\n\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final TokenSuppressorService tokenSuppressorService;\n private final RestTemplate restTemplate = new RestTemplate();\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel processar IA (Groq/Gemini/OpenAI/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, systemPrompt, userPrompt, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String targetModel = resolveTargetModelForSuppressor(cfg, documento);\n String sys = systemPrompt;\n String usr = userPrompt;\n if (tokenSuppressorService.isEnabled()) {\n Optional<TokenSuppressorService.OptimizedPrompt> opt =\n tokenSuppressorService.tryOptimize(userId, sys, usr, targetModel);\n if (opt.isPresent()) {\n sys = opt.get().systemPrompt();\n usr = opt.get().userPrompt();\n }\n }\n final String systemFinal = sys;\n final String userFinal = usr;\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemFinal, userFinal);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n private String resolveTargetModelForSuppressor(AiProvidersConfig cfg, boolean documento) {\n if (platformGeminiApiKey != null && !platformGeminiApiKey.isBlank()) {\n return geminiModel != null && !geminiModel.isBlank() ? geminiModel : \"gemini-2.5-flash\";\n }\n GroqSection g = cfg.getGroq();\n if (g != null) {\n String m = documento ? groqModelDocument : g.getModelText();\n if (m != null && !m.isBlank()) {\n return m;\n }\n }\n OpenaiSection o = cfg.getOpenai();\n if (o != null && o.getModel() != null && !o.getModel().isBlank()) {\n return o.getModel();\n }\n return \"gemini-2.5-flash\";\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : orderedProviders(cfg)) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n throw new RuntimeException(failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\");\n }\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n List<AiProviderType> out = new ArrayList<>();\n if (cfg.getProviderOrder() != null) {\n for (String raw : cfg.getProviderOrder()) {\n AiProviderType t = AiProviderType.fromString(raw);\n if (t != null && !out.contains(t)) {\n out.add(t);\n }\n }\n }\n for (AiProviderType t : AiProviderType.values()) {\n if (!out.contains(t)) {\n out.add(t);\n }\n }\n return out;\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return p != AiProviderType.GEMINI && canChatJson(cfg, p);\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> false;\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case GEMINI -> geminiModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getWhisperModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case GEMINI -> platformGeminiApiKey;\n case OPENAI -> openai(cfg).getApiKey();\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case GEMINI -> geminiBaseUrl;\n case OPENAI -> openai(cfg).getBaseUrl();\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n if (provider == AiProviderType.GEMINI) {\n return parseGeminiJson(model, systemPrompt, userPrompt);\n }\n return parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n aiProvidersConfigService.ensureGeminiInProviderOrder(cfg);\n return cfg;\n }\n}\n"}, "abc1231f8e481dd1fc142caf6dbdc777feffabab": {"t": 1785752845.0151336, "n": 38991, "h": "54c7951943cf17b5", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\n\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final RestTemplate restTemplate = new RestTemplate();\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n @Value(\"${consumoesperto.ai.groq-model-document:llama-3.1-8b-instant}\")\n private String groqModelDocument;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel processar IA (Groq/Gemini/OpenAI/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, systemPrompt, userPrompt, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, false);\n }\n\n /** Extração de PDF/contracheque: modelo Groq mais leve para poupar quota diária. */\n public JsonNode gerarJsonDocumento(Long userId, String systemPrompt, String userPrompt) {\n return gerarJsonInternal(userId, systemPrompt, userPrompt, true);\n }\n\n private JsonNode gerarJsonInternal(Long userId, String systemPrompt, String userPrompt, boolean documento) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = documento ? chatModelForDocument(p, c) : chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n private String chatModelForDocument(AiProviderType p, AiProvidersConfig cfg) {\n if (p == AiProviderType.GROQ) {\n if (groqModelDocument != null && !groqModelDocument.isBlank()) {\n return groqModelDocument.trim();\n }\n String m = groq(cfg).getModelText();\n if (m != null && (m.contains(\"70b\") || m.contains(\"versatile\"))) {\n return \"llama-3.1-8b-instant\";\n }\n return m != null && !m.isBlank() ? m : \"llama-3.1-8b-instant\";\n }\n return chatModelFor(p, cfg);\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : orderedProviders(cfg)) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n throw new RuntimeException(failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\");\n }\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n List<AiProviderType> out = new ArrayList<>();\n if (cfg.getProviderOrder() != null) {\n for (String raw : cfg.getProviderOrder()) {\n AiProviderType t = AiProviderType.fromString(raw);\n if (t != null && !out.contains(t)) {\n out.add(t);\n }\n }\n }\n for (AiProviderType t : AiProviderType.values()) {\n if (!out.contains(t)) {\n out.add(t);\n }\n }\n return out;\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return p != AiProviderType.GEMINI && canChatJson(cfg, p);\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> false;\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case GEMINI -> geminiModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getWhisperModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case GEMINI -> platformGeminiApiKey;\n case OPENAI -> openai(cfg).getApiKey();\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case GEMINI -> geminiBaseUrl;\n case OPENAI -> openai(cfg).getBaseUrl();\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n if (provider == AiProviderType.GEMINI) {\n return parseGeminiJson(model, systemPrompt, userPrompt);\n }\n return parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n aiProvidersConfigService.ensureGeminiInProviderOrder(cfg);\n return cfg;\n }\n}\n"}, "904c1bfb144e32a0a277f9a8562eeefa306d850f": {"t": 1785752846.8817515, "n": 37728, "h": "84f4e100aab0dce3", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\n\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final RestTemplate restTemplate = new RestTemplate();\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|LIST_CARDS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se pedir listar/quantos cartões tem (ex.: 'lista meus cartões', 'quantos cartões eu tenho?'): action LIST_CARDS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel processar IA (Groq/Gemini/OpenAI/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, systemPrompt, userPrompt, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : orderedProviders(cfg)) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n throw new RuntimeException(failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\");\n }\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n List<AiProviderType> out = new ArrayList<>();\n if (cfg.getProviderOrder() != null) {\n for (String raw : cfg.getProviderOrder()) {\n AiProviderType t = AiProviderType.fromString(raw);\n if (t != null && !out.contains(t)) {\n out.add(t);\n }\n }\n }\n for (AiProviderType t : AiProviderType.values()) {\n if (!out.contains(t)) {\n out.add(t);\n }\n }\n return out;\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return p != AiProviderType.GEMINI && canChatJson(cfg, p);\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> false;\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case GEMINI -> geminiModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getWhisperModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case GEMINI -> platformGeminiApiKey;\n case OPENAI -> openai(cfg).getApiKey();\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case GEMINI -> geminiBaseUrl;\n case OPENAI -> openai(cfg).getBaseUrl();\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n if (provider == AiProviderType.GEMINI) {\n return parseGeminiJson(model, systemPrompt, userPrompt);\n }\n return parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n return cfg;\n }\n}\n"}, "879a4eb23c987d6a7495251e37a5b4282d6c7b99": {"t": 1785752848.6631012, "n": 37584, "h": "e9b9fdd016c33104", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\n\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final RestTemplate restTemplate = new RestTemplate();\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"paymentMethod (CONTA quando PIX/TED/débito/transferência na conta bancária; CARTAO quando compra ou gasto na fatura do cartão), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas: paymentMethod=CONTA para PIX, TED, transferência ou débito na conta (ex.: 'fiz um pix de 50 da conta Nubank'); \" +\n \"preencher accountName ou bank com o banco da conta; NÃO preencher cardName nesses casos. \" +\n \"paymentMethod=CARTAO para compras na fatura (ex.: 'gastei 50 no cartão Nubank', 'paguei no Nubank' sem PIX). \" +\n \"Quando houver referência só de cartão/fatura, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel processar IA (Groq/Gemini/OpenAI/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, systemPrompt, userPrompt, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : orderedProviders(cfg)) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n throw new RuntimeException(failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\");\n }\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n List<AiProviderType> out = new ArrayList<>();\n if (cfg.getProviderOrder() != null) {\n for (String raw : cfg.getProviderOrder()) {\n AiProviderType t = AiProviderType.fromString(raw);\n if (t != null && !out.contains(t)) {\n out.add(t);\n }\n }\n }\n for (AiProviderType t : AiProviderType.values()) {\n if (!out.contains(t)) {\n out.add(t);\n }\n }\n return out;\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return p != AiProviderType.GEMINI && canChatJson(cfg, p);\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> false;\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case GEMINI -> geminiModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getWhisperModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case GEMINI -> platformGeminiApiKey;\n case OPENAI -> openai(cfg).getApiKey();\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case GEMINI -> geminiBaseUrl;\n case OPENAI -> openai(cfg).getBaseUrl();\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n if (provider == AiProviderType.GEMINI) {\n return parseGeminiJson(model, systemPrompt, userPrompt);\n }\n return parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n return cfg;\n }\n}\n"}, "d2ecd0e213c1a43eedd0cd08daa95f4e66b23745": {"t": 1785752851.46636, "n": 37075, "h": "07acc062d8b0472c", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\n\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final RestTemplate restTemplate = new RestTemplate();\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|CREATE_META|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas, quando houver referência de cartão/banco, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Se for cadastrar/criar/registrar/adicionar *nova meta financeira* (objetivo de poupança), ex.: \" +\n \"'cadastra meta geladeira', 'criar uma meta chamada viagem', 'registrar meta notebook 5000 12%': \" +\n \"action CREATE_META; description = nome da meta; amount = valor total se citado; percentualComprometimento se citado. \" +\n \"NÃO use MANAGE_ENTITY para criar meta — MANAGE_ENTITY é só apagar/editar meta *existente*.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro) para apagar/editar, preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser *simular prazo* de compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'quanto tempo para geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel processar IA (Groq/Gemini/OpenAI/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, systemPrompt, userPrompt, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : orderedProviders(cfg)) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n throw new RuntimeException(failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\");\n }\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n List<AiProviderType> out = new ArrayList<>();\n if (cfg.getProviderOrder() != null) {\n for (String raw : cfg.getProviderOrder()) {\n AiProviderType t = AiProviderType.fromString(raw);\n if (t != null && !out.contains(t)) {\n out.add(t);\n }\n }\n }\n for (AiProviderType t : AiProviderType.values()) {\n if (!out.contains(t)) {\n out.add(t);\n }\n }\n return out;\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return p != AiProviderType.GEMINI && canChatJson(cfg, p);\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> false;\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case GEMINI -> geminiModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getWhisperModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case GEMINI -> platformGeminiApiKey;\n case OPENAI -> openai(cfg).getApiKey();\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case GEMINI -> geminiBaseUrl;\n case OPENAI -> openai(cfg).getBaseUrl();\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n if (provider == AiProviderType.GEMINI) {\n return parseGeminiJson(model, systemPrompt, userPrompt);\n }\n return parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n return cfg;\n }\n}\n"}, "8558a900dea81b72142aabb51833a314dc070c50": {"t": 1785752853.9940212, "n": 36682, "h": "9bcfadce0e4e7184", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\n\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final RestTemplate restTemplate = new RestTemplate();\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|CREATE_BANK_ACCOUNT|CREATE_CATEGORY|CREATE_BUDGET|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"accountName (nome da conta bancária/carteira), accountType (CORRENTE|POUPANCA|DINHEIRO), initialBalance (saldo inicial da conta), \" +\n \"categoryName (nome da categoria), budgetLimit (limite do orçamento mensal), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao|conta_bancaria|categoria|orcamento), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|CONTA_BANCARIA|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas, quando houver referência de cartão/banco, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Se for cadastro de conta bancária/carteira (ex.: 'cria conta Nubank corrente saldo 1500', 'cadastra carteira dinheiro'): \" +\n \"action CREATE_BANK_ACCOUNT; accountName = nome; accountType = CORRENTE|POUPANCA|DINHEIRO; initialBalance ou amount = saldo inicial.\\n\" +\n \"- Se for cadastro de categoria (ex.: 'cria categoria Pets', 'nova categoria Viagem'): action CREATE_CATEGORY; categoryName = nome.\\n\" +\n \"- Se for cadastro de orçamento mensal (ex.: 'orçamento 800 em Alimentação', 'limite 500 para Lazer este mês'): \" +\n \"action CREATE_BUDGET; categoryName = categoria citada; budgetLimit ou amount = valor limite; reportMonth/reportYear se citar mês.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro), preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; se citar *conta bancária/carteira/poupança* (não cartão), manageTarget=conta_bancaria; \" +\n \"se citar *categoria*, manageTarget=categoria; se citar *orçamento/orcamento*, manageTarget=orcamento; \" +\n \"caso contrário manageTarget=transacao. \" +\n \"Não confundir 'meta' de simulação de compra com meta financeira — só use manageTarget=meta quando for cadastro de meta.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca categoria, meta, despesa fixa, conta bancária, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"conta bancária (targetEntity CONTA_BANCARIA): nome, tipo, saldo, padrao; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser simular compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter', \" +\n \"'inativa conta Nubank', 'apaga categoria Pets', 'remove orçamento de Alimentação'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao | conta_bancaria | categoria | orcamento; \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel processar IA (Groq/Gemini/OpenAI/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, systemPrompt, userPrompt, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : orderedProviders(cfg)) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n throw new RuntimeException(failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\");\n }\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n List<AiProviderType> out = new ArrayList<>();\n if (cfg.getProviderOrder() != null) {\n for (String raw : cfg.getProviderOrder()) {\n AiProviderType t = AiProviderType.fromString(raw);\n if (t != null && !out.contains(t)) {\n out.add(t);\n }\n }\n }\n for (AiProviderType t : AiProviderType.values()) {\n if (!out.contains(t)) {\n out.add(t);\n }\n }\n return out;\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return p != AiProviderType.GEMINI && canChatJson(cfg, p);\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> false;\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case GEMINI -> geminiModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getWhisperModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case GEMINI -> platformGeminiApiKey;\n case OPENAI -> openai(cfg).getApiKey();\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case GEMINI -> geminiBaseUrl;\n case OPENAI -> openai(cfg).getBaseUrl();\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n if (provider == AiProviderType.GEMINI) {\n return parseGeminiJson(model, systemPrompt, userPrompt);\n }\n return parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n return cfg;\n }\n}\n"}, "6d0c75031f28b4b74b7e6c4f396136afa5fe390c": {"t": 1785752855.556819, "n": 35219, "h": "184dc0196145a643", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\n\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final RestTemplate restTemplate = new RestTemplate();\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas, quando houver referência de cartão/banco, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro), preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; caso contrário manageTarget=transacao. \" +\n \"Não confundir 'meta' de simulação de compra com meta financeira — só use manageTarget=meta quando for cadastro de meta.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca primeiro categoria, depois meta, despesa fixa, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão/conta: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser simular compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao (obrigatório conforme prioridade acima); \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel processar IA (Groq/Gemini/OpenAI/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, systemPrompt, userPrompt, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : orderedProviders(cfg)) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n throw new RuntimeException(failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\");\n }\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n List<AiProviderType> out = new ArrayList<>();\n if (cfg.getProviderOrder() != null) {\n for (String raw : cfg.getProviderOrder()) {\n AiProviderType t = AiProviderType.fromString(raw);\n if (t != null && !out.contains(t)) {\n out.add(t);\n }\n }\n }\n for (AiProviderType t : AiProviderType.values()) {\n if (!out.contains(t)) {\n out.add(t);\n }\n }\n return out;\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return p != AiProviderType.GEMINI && canChatJson(cfg, p);\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> false;\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case GEMINI -> geminiModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getWhisperModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case GEMINI -> platformGeminiApiKey;\n case OPENAI -> openai(cfg).getApiKey();\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case GEMINI -> geminiBaseUrl;\n case OPENAI -> openai(cfg).getBaseUrl();\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n if (provider == AiProviderType.GEMINI) {\n return parseGeminiJson(model, systemPrompt, userPrompt);\n }\n return parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n aiProvidersConfigService.applyOllamaMasterFallback(cfg);\n return cfg;\n }\n}\n"}, "6fd871960e68c72e0f2185e29d3613edd694fc2f": {"t": 1785752857.4579985, "n": 35154, "h": "6dc34554690eba58", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\n\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final RestTemplate restTemplate = new RestTemplate();\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n Usuario uEnt = ou.orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(uEnt);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas, quando houver referência de cartão/banco, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro), preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; caso contrário manageTarget=transacao. \" +\n \"Não confundir 'meta' de simulação de compra com meta financeira — só use manageTarget=meta quando for cadastro de meta.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca primeiro categoria, depois meta, despesa fixa, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão/conta: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser simular compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao (obrigatório conforme prioridade acima); \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel processar IA (Groq/Gemini/OpenAI/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Resposta analítica usando contexto RAG (transações indexadas em pgvector).\n */\n public String gerarRespostaAnaliticaRag(Long userId, String pergunta, String contextoTransacoes) {\n if (userId == null || pergunta == null || pergunta.isBlank()\n || contextoTransacoes == null || contextoTransacoes.isBlank()) {\n return \"\";\n }\n Usuario u = usuarioRepository.findById(userId).orElse(null);\n String persona = jarvisProtocolService.camadaPersonaCompletaParaIa(u);\n String system = persona\n + \"Com base *somente* nos trechos de transações fornecidos, responda à pergunta com análise útil \"\n + \"(totais, padrões, tendências). Se os trechos não forem suficientes, indique-o com franqueza. \"\n + \"Texto corrido; listas curtas permitidas. Retorne JSON {\\\"texto\\\":\\\"...\\\"}.\";\n String userPrompt = \"Pergunta: \" + pergunta + \"\\n\\nTrechos indexados (RAG / pgvector):\\n\" + contextoTransacoes;\n return gerarTexto(\n userId,\n system,\n userPrompt,\n \"Ainda não há memória semântica de transações suficiente para responder com precisão. Faça mais lançamentos ou reformule.\"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, systemPrompt, userPrompt, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : orderedProviders(cfg)) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n throw new RuntimeException(failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\");\n }\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n List<AiProviderType> out = new ArrayList<>();\n if (cfg.getProviderOrder() != null) {\n for (String raw : cfg.getProviderOrder()) {\n AiProviderType t = AiProviderType.fromString(raw);\n if (t != null && !out.contains(t)) {\n out.add(t);\n }\n }\n }\n for (AiProviderType t : AiProviderType.values()) {\n if (!out.contains(t)) {\n out.add(t);\n }\n }\n return out;\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return p != AiProviderType.GEMINI && canChatJson(cfg, p);\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> false;\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case GEMINI -> geminiModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getWhisperModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case GEMINI -> platformGeminiApiKey;\n case OPENAI -> openai(cfg).getApiKey();\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case GEMINI -> geminiBaseUrl;\n case OPENAI -> openai(cfg).getBaseUrl();\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n if (provider == AiProviderType.GEMINI) {\n return parseGeminiJson(model, systemPrompt, userPrompt);\n }\n return parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n return cfg;\n }\n}\n"}, "762b995a131ff9c43b3c4ccaeec47d887d70f51d": {"t": 1785752859.2493203, "n": 34126, "h": "987589266af06117", "c": "package com.consumoesperto.service;\n\nimport com.consumoesperto.model.Usuario;\nimport com.consumoesperto.repository.UsuarioRepository;\nimport com.consumoesperto.service.AiProvidersConfigService.AiProvidersConfig;\nimport com.consumoesperto.service.AiProvidersConfigService.GroqSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OllamaSection;\nimport com.consumoesperto.service.AiProvidersConfigService.OpenaiSection;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.core.io.ByteArrayResource;\nimport org.springframework.http.*;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestTemplate;\n\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Predicate;\n\n@Service\n@RequiredArgsConstructor\n@Slf4j\npublic class OpenAiService {\n\n private final ObjectMapper objectMapper;\n private final AiProvidersConfigService aiProvidersConfigService;\n private final UsuarioRepository usuarioRepository;\n private final JarvisProtocolService jarvisProtocolService;\n private final RestTemplate restTemplate = new RestTemplate();\n\n @Value(\"${consumoesperto.ai.platform-gemini-api-key:}\")\n private String platformGeminiApiKey;\n\n @Value(\"${consumoesperto.ai.gemini-base-url:https://generativelanguage.googleapis.com/v1beta}\")\n private String geminiBaseUrl;\n\n @Value(\"${consumoesperto.ai.gemini-model:gemini-2.5-flash}\")\n private String geminiModel;\n\n /** Modelo exclusivo OpenAI / endpoint compatível {@code /v1/embeddings}. */\n @Value(\"${consumoesperto.ai.embedding-model:text-embedding-3-small}\")\n private String embeddingModel;\n\n public String transcribeAudio(byte[] audioBytes, String filename, String contentType, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canTranscribe(cfg, p),\n (p, c) -> transcribeForProvider(p, c, audioBytes, filename),\n \"Nenhum provedor de transcrição disponível. Detalhes: \"\n );\n }\n\n public JsonNode parseCommand(String inputText, Long userId) {\n AiProvidersConfig cfg = cfgForAi(userId);\n Optional<Usuario> ou = userId == null ? Optional.empty() : usuarioRepository.findById(userId);\n String vocativoCompleto = ou.map(jarvisProtocolService::montarVocativoCompleto).orElse(\"Senhor\");\n String instrucaoInterlocutor = ou.map(jarvisProtocolService::instrucaoInterlocutorJarvis).orElse(\"\");\n String persona = instrucaoInterlocutor + jarvisProtocolService.jarvisPersonaSystemLayer(vocativoCompleto);\n String systemPrompt = persona + \"Você converte comandos financeiros em JSON estrito. \" +\n \"Retorne apenas JSON sem markdown. Campos: \" +\n \"action (CREATE_EXPENSE|CREATE_INCOME|CREATE_CARD|UPDATE_ENTITY_CONFIG|UPDATE_ACCOUNT_CONFIG|SIMULATE_PURCHASE_GOAL|GET_INSIGHTS|CHECK_CARD_STATUS|FORECAST_MONTH|GENERATE_REPORT|GERAR_RELATORIO|SET_SALARY_CONFIG|MANAGE_ENTITY|UNKNOWN), \" +\n \"reportMonth (1-12, opcional), reportYear (ex.: 2026, opcional — default mês/ano correntes), \" +\n \"description, amount, bank, cardName, cardNumber, dueDay, creditLimit (limite total do cartão, opcional), \" +\n \"installmentCount (N parcelas, inteiro ≥2), installmentAmount (valor de cada parcela quando citado), \" +\n \"interestFree (true se 'sem juros'/'s/juros'), withInterest (true se 'com juros'), purchasePrice (preço à vista do bem quando citado), \" +\n \"newAvailableLimit (opcional), percentualComprometimento (0-100 quando for meta), \" +\n \"manageOperation (delete|edit), manageTarget (transacao|meta|cartao), searchPhrase (termo de busca), \" +\n \"targetEntity (AUTO|CONTA|CARTAO|META|CATEGORIA|DESPESA_FIXA), identifier (apelido/nome do cadastro), \" +\n \"updates (objeto JSON com campos a alterar, ex.: {\\\"limite\\\":5000,\\\"apelido\\\":\\\"Nubank Ultra\\\",\\\"icone\\\":\\\"shopping-cart\\\"}), \" +\n \"legado cartão: newLimit, newAvailableLimit, newCardName — use UPDATE_ACCOUNT_CONFIG ou UPDATE_ENTITY_CONFIG com updates.\\n\" +\n \"confianca (0-1), errorMessage. \" +\n \"Se a frase citar cartão/banco (ex: 'paguei 20 no Nubank'), preencha cardName e/ou bank.\";\n\n String userPrompt = \"Texto do usuário: \" + inputText + \"\\n\" +\n \"Regras:\\n\" +\n \"- Se for despesa: action CREATE_EXPENSE e preencher description + amount.\\n\" +\n \"- Parcelamento no cartão (CREATE_EXPENSE): obrigatório cartão (cardName/bank). \" +\n \"Sem juros (ex.: '100 reais no Nubank em 2 vezes sem juros'): installmentCount=2, interestFree=true, amount=valor total (100), purchasePrice vazio.\\n\" +\n \"Com juros explícito (ex.: 'TV 2000 no Inter em 10 vezes de 250 com juros'): installmentCount=10, installmentAmount=250, withInterest=true, \" +\n \"purchasePrice=2000 (à vista), amount pode ser 250 (parcela) ou 2000 — o backend usa purchasePrice + installmentAmount.\\n\" +\n \"Se o utilizador disser só 'N vezes de X' sem 'sem juros' nem 'com juros' e N*X > total citado, o backend pede confirmação de juros; \" +\n \"ainda assim preencha installmentCount, installmentAmount e amount com o total citado à vista quando existir.\\n\" +\n \"- Em despesas, quando houver referência de cartão/banco, preencher cardName/bank.\\n\" +\n \"- Se for receita: action CREATE_INCOME e preencher description + amount.\\n\" +\n \"- Se for cadastro de cartão: action CREATE_CARD e preencher cardName, bank, cardNumber (últimos 4 dígitos) e dueDay (1-31); \" +\n \"se a frase citar limite (ex.: 'limite 7800'), preencher creditLimit com o número; newAvailableLimit só se disser limite disponível separado.\\n\" +\n \"- Prioridade MANAGE_ENTITY: se a frase citar explicitamente *meta* (objetivo financeiro), preencha manageTarget=meta; \" +\n \"se citar *cartão/cartao/card*, manageTarget=cartao; caso contrário manageTarget=transacao. \" +\n \"Não confundir 'meta' de simulação de compra com meta financeira — só use manageTarget=meta quando for cadastro de meta.\\n\" +\n \"- Se houver mais de um item candidato a editar/apagar, o bot no WhatsApp deve listar numerado e pedir o número; \" +\n \"no JSON, preencha searchPhrase com o termo original (mesmo com erro de digitação; o backend corrige por similaridade).\\n\" +\n \"- Se for editar cadastro existente (categoria, meta, despesa fixa, cartão/conta), use UPDATE_ENTITY_CONFIG: \" +\n \"targetEntity = AUTO se o usuário não especificar tipo (o sistema busca primeiro categoria, depois meta, despesa fixa, cartão); \" +\n \"identifier = nome citado; updates = mapa com chaves canônicas: \" +\n \"cartão/conta: apelido, banco, limite, limiteDisponivel, cor, icone; \" +\n \"categoria: nome, cor, icone (limiteMensal pode ser pedido mas o app pode ignorar); \" +\n \"meta: nome ou descricao, valorObjetivo ou valorTotal, percentual, prioridade, dataPrazo (yyyy-MM-dd); \" +\n \"despesa fixa: descricao, valor.\\n\" +\n \"- Atalho legado só para cartão: UPDATE_ACCOUNT_CONFIG com cardName, newLimit, newAvailableLimit, newCardName.\\n\" +\n \"- Se o usuário quiser simular compra/meta (ex: 'quero comprar uma TV de 2000 usando 10% da minha renda', \" +\n \"'geladeira 3500 comprometendo 15% do salário'): action SIMULATE_PURCHASE_GOAL, description = item, \" +\n \"amount = valor total do bem, percentualComprometimento = percentual informado (número, ex: 10 para 10%).\\n\" +\n \"- Se perguntar sobre recorrência, assinaturas repetidas, gastos fixos mensais (ex: 'tenho recorrência?', 'o que repete?'): action GET_INSIGHTS.\\n\" +\n \"- Se perguntar quanto gastou no cartão, resumo de fatura, limite disponível (ex: 'quanto gastei no Nubank?', 'resumo da fatura do Inter'): \" +\n \"action CHECK_CARD_STATUS e preencher cardName e/ou bank com o cartão citado.\\n\" +\n \"- Se perguntar como vai fechar o mês, se vai ficar no vermelho, previsão/projeção do mês ou saldo no fim do mês: action FORECAST_MONTH.\\n\" +\n \"- Se perguntar onde investir o saldo, saldo parado rendendo, poupança vs CDB vs Tesouro Selic: action SUGERIR_INVESTIMENTO.\\n\" +\n \"- Se perguntar se vale a pena comprar agora no cartão, quando a fatura fecha/vira, melhor dia para comprar, \" +\n \"prazo de pagamento ou alavancagem de caixa (ex: 'vale a pena comprar agora no Nubank?', 'quando meu cartão vira?', \" +\n \"'é bom comprar notebook hoje no Inter?'): action CHECK_CARD_STATUS com cardName/bank; o sistema responderá com \" +\n \"estratégia de fechamento e vencimento (foco em prazo, não só saldo).\\n\" +\n \"- Se pedir relatório ou PDF mensal (ex: 'gera um PDF do mês', 'relatório de maio', 'quero o resumo em PDF'): \" +\n \"action GENERATE_REPORT; preencher reportMonth e reportYear quando a frase citar mês/ano (ex: maio 2026 → 5 e 2026).\\n\" +\n \"- Sinónimo: 'gerar relatorio', 'GERAR_RELATORIO', 'manda o pdf' → action GERAR_RELATORIO (mesmos campos que GENERATE_REPORT).\\n\" +\n \"- Apagar ou editar algo pelo nome (ex: 'apague a gasolina deste mês', 'edite minha meta de Lazer', 'apague meu cartão Inter'): \" +\n \"action MANAGE_ENTITY; manageOperation = delete ou edit; manageTarget = transacao | meta | cartao (obrigatório conforme prioridade acima); \" +\n \"searchPhrase = termo principal (ex.: gasolina, Lazer, Inter); para transações no mês corrente use reportMonth/reportYear ou deixe vazio para mês atual; \" +\n \"tipoTransacao DESPESA ou RECEITA quando for transação (default DESPESA se for gasto).\\n\" +\n \"- Se configurar salário / renda com descontos (ex: 'salário bruto 8000, 600 INSS, 400 plano, 500 IRRF, pagamento dia 5'): \" +\n \"action SET_SALARY_CONFIG; preencher updates como objeto JSON: \" +\n \"salarioBruto (número), diaPagamento (1-31), descontosFixos como array de objetos \" +\n \"{ \\\"rotulo\\\": \\\"INSS\\\", \\\"valor\\\": 600 } (use rotulo ou label; valor ou amount numérico). \" +\n \"Se a frase não disser o dia, inferir com cuidado ou usar UNKNOWN pedindo o dia.\\n\" +\n \"- Se faltar dado essencial, retornar action UNKNOWN com errorMessage explicando o que faltou.\\n\" +\n \"- amount deve ser número decimal sem símbolo de moeda.\\n\" +\n \"- Sempre retornar o campo confianca com valor entre 0 e 1.\";\n\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel processar IA (Groq/Gemini/OpenAI/Ollama). Detalhes: \"\n );\n }\n\n /**\n * Uma linha de insight para o PDF mensal (dados já agregados; não expõe outras contas).\n */\n public String gerarInsightRelatorioMaiorGasto(\n Long userId,\n int mes,\n int ano,\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Retorne estritamente JSON sem markdown: {\\\"insight\\\":\\\"...\\\"}. \"\n + \"O campo insight deve ser UMA única frase curta (máximo 160 caracteres), em português europeu, \"\n + \"tom profissional e neutro, comentando o maior gasto do mês em relação ao total. Sem emojis.\";\n String userPrompt = \"Período: \" + String.format(\"%02d/%d\", mes, ano)\n + \". Categoria com maior despesa: \" + categoriaMaiorGasto\n + \". Valor nessa categoria: \" + valorMaiorGasto.stripTrailingZeros().toPlainString()\n + \". Total de despesas confirmadas no mês: \" + totalDespesasMes.stripTrailingZeros().toPlainString() + \".\";\n\n try {\n String linha = executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n JsonNode j = parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n return j.path(\"insight\").asText(\"\").trim();\n },\n \"Insight relatório: \"\n );\n if (linha.isBlank()) {\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n return linha.length() > 200 ? linha.substring(0, 197) + \"…\" : linha;\n } catch (Exception e) {\n log.warn(\"Insight relatório PDF indisponível: {}\", e.getMessage());\n return fallbackInsight(categoriaMaiorGasto, valorMaiorGasto, totalDespesasMes);\n }\n }\n\n private static String fallbackInsight(\n String categoriaMaiorGasto,\n BigDecimal valorMaiorGasto,\n BigDecimal totalDespesasMes\n ) {\n if (totalDespesasMes == null || totalDespesasMes.compareTo(BigDecimal.ZERO) <= 0) {\n return \"Concentre-se em categorizar e rever despesas no app para ganhar previsibilidade.\";\n }\n BigDecimal pct = valorMaiorGasto != null\n ? valorMaiorGasto.multiply(BigDecimal.valueOf(100)).divide(totalDespesasMes, 0, RoundingMode.HALF_UP)\n : BigDecimal.ZERO;\n return \"O maior volume mensal concentrou-se em «\" + categoriaMaiorGasto + \"» (~\" + pct + \"% do total) — vale rever se está alinhado com as suas metas.\";\n }\n\n public JsonNode analisarImagemNotaFiscal(String imageUrl, Long userId) {\n if (imageUrl == null || imageUrl.isBlank()) {\n throw new RuntimeException(\"URL da imagem não informada\");\n }\n return analisarImagemNotaFiscalConteudo(imageUrl, userId);\n }\n\n public JsonNode analisarImagemNotaFiscal(byte[] imageBytes, String contentType, Long userId) {\n if (imageBytes == null || imageBytes.length == 0) {\n throw new RuntimeException(\"Imagem não informada para OCR\");\n }\n String safeContentType = (contentType == null || contentType.isBlank()) ? \"image/jpeg\" : contentType;\n String base64 = Base64.getEncoder().encodeToString(imageBytes);\n String dataUrl = \"data:\" + safeContentType + \";base64,\" + base64;\n return analisarImagemNotaFiscalConteudo(dataUrl, userId);\n }\n\n private JsonNode analisarImagemNotaFiscalConteudo(String imageSource, Long userId) {\n log.info(\"[VISION-LOG] Iniciando OCR de cupom userId={}\", userId);\n AiProvidersConfig cfg = cfgForAi(userId);\n String systemPrompt = \"Você é um extrator OCR financeiro especializado em cupons/notas fiscais brasileiras. \" +\n \"Analise a imagem e retorne estritamente JSON sem markdown.\";\n String userPrompt = \"Analise esta imagem de cupom fiscal e extraia os campos: \" +\n \"valorTotal (double), estabelecimento (string), dataCompra (yyyy-MM-dd), categoriaSugerida (string), \" +\n \"cnpj (string, se visível), confianca (0 a 1), erro (string opcional). \" +\n \"Se não for possível ler com segurança, retorne erro preenchido e confianca baixa.\";\n\n JsonNode out = executeAIRequestWithFallback(\n cfg,\n p -> canVision(cfg, p),\n (p, c) -> {\n String model = visionModelFor(p, c);\n log.info(\"[VISION-LOG] Provedor={} modelo={} userId={}\", p.name(), model, userId);\n return parseVisionOpenAiCompatible(p.name(), apiKeyFor(p, c), baseUrlFor(p, c), model, systemPrompt, userPrompt, imageSource);\n },\n \"Falha OCR em todos provedores (Groq/OpenAI/Ollama): \"\n );\n log.info(\"[VISION-LOG] OCR concluído userId={} confianca={}\", userId, out.path(\"confianca\").asDouble(Double.NaN));\n return out;\n }\n\n public JsonNode gerarJson(Long userId, String systemPrompt, String userPrompt) {\n AiProvidersConfig cfg = cfgForAi(userId);\n return executeAIRequestWithFallback(\n cfg,\n p -> canChatJson(cfg, p),\n (p, c) -> {\n String model = chatModelFor(p, c);\n return parseChatJsonForProvider(p, c, model, systemPrompt, userPrompt);\n },\n \"Nao foi possivel gerar JSON via IA. Detalhes: \"\n );\n }\n\n public String gerarTexto(Long userId, String systemPrompt, String userPrompt, String fallback) {\n try {\n JsonNode json = gerarJson(userId,\n systemPrompt + \" Retorne estritamente JSON sem markdown no formato {\\\"texto\\\":\\\"...\\\"}.\",\n userPrompt);\n String texto = json.path(\"texto\").asText(\"\").trim();\n return texto.isBlank() ? fallback : texto;\n } catch (Exception e) {\n log.warn(\"Geração de texto IA indisponível: {}\", e.getMessage());\n return fallback;\n }\n }\n\n /**\n * Embeddings OpenAI-compatíveis ({@code POST .../embeddings}). A camada PG usa 1536 dimensões.\n */\n public Optional<float[]> tryCreateEmbedding(String text, Long userId) {\n if (text == null || text.isBlank()) {\n return Optional.empty();\n }\n try {\n return Optional.of(createEmbeddingOpenAi(text.trim(), userId));\n } catch (Exception e) {\n log.warn(\"Embedding indisponível: {}\", e.getMessage());\n return Optional.empty();\n }\n }\n\n private float[] createEmbeddingOpenAi(String text, Long userId) throws Exception {\n AiProvidersConfig cfg = cfgForAi(userId);\n OpenaiSection o = openai(cfg);\n if (!hasKey(o) || !hasUrl(o.getBaseUrl())) {\n throw new RuntimeException(\"OpenAI (embeddings): API key ou base URL ausente\");\n }\n String model = embeddingModel == null || embeddingModel.isBlank() ? \"text-embedding-3-small\" : embeddingModel.trim();\n Map<String, Object> payload = Map.of(\"model\", model, \"input\", text);\n HttpHeaders headers = new HttpHeaders();\n headers.setBearerAuth(o.getApiKey().trim());\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(o.getBaseUrl()) + \"/embeddings\";\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n JsonNode root = objectMapper.readTree(response.getBody());\n JsonNode emb = root.path(\"data\").path(0).path(\"embedding\");\n if (!emb.isArray() || emb.size() == 0) {\n throw new RuntimeException(\"Resposta de embeddings sem vetor\");\n }\n int n = emb.size();\n float[] out = new float[n];\n for (int i = 0; i < n; i++) {\n out[i] = (float) emb.get(i).asDouble();\n }\n return out;\n }\n\n /**\n * Percorre {@link AiProvidersConfig#getProviderOrder()} (completando provedores faltantes) até o primeiro sucesso.\n */\n private <T> T executeAIRequestWithFallback(\n AiProvidersConfig cfg,\n Predicate<AiProviderType> canUseProvider,\n FallbackAttempt<T> attempt,\n String failureMessagePrefix\n ) {\n List<String> errors = new ArrayList<>();\n for (AiProviderType provider : orderedProviders(cfg)) {\n if (!canUseProvider.test(provider)) {\n continue;\n }\n log.debug(\"Usando provedor {} (config usuario)\", provider.name());\n try {\n return attempt.execute(provider, cfg);\n } catch (Exception e) {\n errors.add(provider.name() + \": \" + e.getMessage());\n log.warn(\"Falha no provedor {}: {}\", provider.name(), e.getMessage());\n }\n }\n if (errors.isEmpty()) {\n throw new RuntimeException(failureMessagePrefix + \"nenhum provedor elegível (credenciais/URL ausentes).\");\n }\n throw new RuntimeException(failureMessagePrefix + String.join(\" | \", errors));\n }\n\n @FunctionalInterface\n private interface FallbackAttempt<T> {\n T execute(AiProviderType provider, AiProvidersConfig cfg) throws Exception;\n }\n\n private List<AiProviderType> orderedProviders(AiProvidersConfig cfg) {\n List<AiProviderType> out = new ArrayList<>();\n if (cfg.getProviderOrder() != null) {\n for (String raw : cfg.getProviderOrder()) {\n AiProviderType t = AiProviderType.fromString(raw);\n if (t != null && !out.contains(t)) {\n out.add(t);\n }\n }\n }\n for (AiProviderType t : AiProviderType.values()) {\n if (!out.contains(t)) {\n out.add(t);\n }\n }\n return out;\n }\n\n private boolean canChatJson(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> platformGeminiApiKey != null && !platformGeminiApiKey.isBlank() && hasUrl(geminiBaseUrl);\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private boolean canVision(AiProvidersConfig cfg, AiProviderType p) {\n return p != AiProviderType.GEMINI && canChatJson(cfg, p);\n }\n\n private boolean canTranscribe(AiProvidersConfig cfg, AiProviderType p) {\n return switch (p) {\n case GROQ -> hasKey(groq(cfg)) && hasUrl(groq(cfg).getBaseUrl());\n case GEMINI -> false;\n case OPENAI -> hasKey(openai(cfg)) && hasUrl(openai(cfg).getBaseUrl());\n case OLLAMA -> hasUrl(ollama(cfg).getBaseUrl());\n };\n }\n\n private String chatModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelText();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String visionModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getModelVision();\n case GEMINI -> geminiModel;\n case OPENAI -> {\n String m = openai(cfg).getModel();\n if (m != null && !m.isBlank()\n && (m.contains(\"gpt-4\") || m.contains(\"gpt-5\") || m.contains(\"vision\") || m.contains(\"o4\"))) {\n yield m;\n }\n yield \"gpt-4o\";\n }\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String whisperModelFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getWhisperModel();\n case GEMINI -> geminiModel;\n case OPENAI -> openai(cfg).getWhisperModel();\n case OLLAMA -> ollama(cfg).getModel();\n };\n }\n\n private String apiKeyFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getApiKey();\n case GEMINI -> platformGeminiApiKey;\n case OPENAI -> openai(cfg).getApiKey();\n case OLLAMA -> null;\n };\n }\n\n private String baseUrlFor(AiProviderType p, AiProvidersConfig cfg) {\n return switch (p) {\n case GROQ -> groq(cfg).getBaseUrl();\n case GEMINI -> geminiBaseUrl;\n case OPENAI -> openai(cfg).getBaseUrl();\n case OLLAMA -> ollama(cfg).getBaseUrl();\n };\n }\n\n private GroqSection groq(AiProvidersConfig cfg) {\n return cfg.getGroq() != null ? cfg.getGroq() : new GroqSection();\n }\n\n private OpenaiSection openai(AiProvidersConfig cfg) {\n return cfg.getOpenai() != null ? cfg.getOpenai() : new OpenaiSection();\n }\n\n private OllamaSection ollama(AiProvidersConfig cfg) {\n return cfg.getOllama() != null ? cfg.getOllama() : new OllamaSection();\n }\n\n private static boolean hasKey(OpenaiSection o) {\n return o.getApiKey() != null && !o.getApiKey().isBlank();\n }\n\n private static boolean hasKey(GroqSection g) {\n return g.getApiKey() != null && !g.getApiKey().isBlank();\n }\n\n private static boolean hasUrl(String u) {\n return u != null && !u.isBlank();\n }\n\n private String transcribeForProvider(AiProviderType provider, AiProvidersConfig cfg, byte[] audioBytes, String filename) {\n String key = apiKeyFor(provider, cfg);\n String baseUrl = baseUrlFor(provider, cfg);\n String model = whisperModelFor(provider, cfg);\n ensureBaseUrl(provider.name(), baseUrl);\n if (provider != AiProviderType.OLLAMA && (key == null || key.isBlank())) {\n throw new RuntimeException(provider.name() + \"_API_KEY não configurada\");\n }\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.MULTIPART_FORM_DATA);\n\n ByteArrayResource audioResource = new ByteArrayResource(audioBytes) {\n @Override\n public String getFilename() {\n return filename;\n }\n };\n\n MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();\n body.add(\"model\", model);\n body.add(\"response_format\", \"text\");\n body.add(\"file\", audioResource);\n\n HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);\n ResponseEntity<String> response = restTemplate.exchange(\n trimTrailingSlash(baseUrl) + \"/audio/transcriptions\",\n HttpMethod.POST,\n entity,\n String.class\n );\n\n String raw = response.getBody();\n if (raw == null || raw.isBlank()) {\n throw new RuntimeException(provider.name() + \" retornou transcrição vazia\");\n }\n log.info(\"Transcrição concluída via {}\", provider.name());\n return raw.trim();\n }\n\n private JsonNode parseChatJsonForProvider(AiProviderType provider, AiProvidersConfig cfg, String model,\n String systemPrompt, String userPrompt) {\n if (provider == AiProviderType.GEMINI) {\n return parseGeminiJson(model, systemPrompt, userPrompt);\n }\n return parseCommandOpenAiCompatible(provider.name(), apiKeyFor(provider, cfg), baseUrlFor(provider, cfg),\n model, systemPrompt, userPrompt);\n }\n\n private JsonNode parseGeminiJson(String model, String systemPrompt, String userPrompt) {\n if (platformGeminiApiKey == null || platformGeminiApiKey.isBlank()) {\n throw new RuntimeException(\"GEMINI_API_KEY não configurada\");\n }\n ensureBaseUrl(\"GEMINI\", geminiBaseUrl);\n\n Map<String, Object> payload = Map.of(\n \"systemInstruction\", Map.of(\n \"parts\", List.of(Map.of(\"text\", systemPrompt))\n ),\n \"contents\", List.of(\n Map.of(\n \"role\", \"user\",\n \"parts\", List.of(Map.of(\"text\", userPrompt))\n )\n ),\n \"generationConfig\", Map.of(\n \"temperature\", 0.1,\n \"responseMimeType\", \"application/json\"\n )\n );\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n String url = trimTrailingSlash(geminiBaseUrl) + \"/models/\" + model + \":generateContent?key=\" + platformGeminiApiKey.trim();\n ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);\n return extractJsonFromGeminiResponse(response.getBody());\n }\n\n private JsonNode parseCommandOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n )\n );\n\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"comando\");\n }\n\n private JsonNode parseVisionOpenAiCompatible(String providerName, String key, String providerBaseUrl, String model,\n String systemPrompt, String userPrompt, String imageSource) {\n ensureOpenAiCompatibleConfigured(providerName, key, providerBaseUrl);\n Map<String, Object> payload = Map.of(\n \"model\", model,\n \"temperature\", 0.1,\n \"response_format\", Map.of(\"type\", \"json_object\"),\n \"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\n \"role\", \"user\",\n \"content\", List.of(\n Map.of(\"type\", \"text\", \"text\", userPrompt),\n Map.of(\"type\", \"image_url\", \"image_url\", Map.of(\"url\", imageSource))\n )\n )\n )\n );\n ResponseEntity<String> response = callOpenAiCompatible(providerBaseUrl, key, payload);\n return extractJsonFromOpenAiCompatibleResponse(response.getBody(), providerName, \"ocr\");\n }\n\n private ResponseEntity<String> callOpenAiCompatible(String providerBaseUrl, String key, Map<String, Object> payload) {\n HttpHeaders headers = new HttpHeaders();\n if (key != null && !key.isBlank()) {\n headers.setBearerAuth(key);\n }\n headers.setContentType(MediaType.APPLICATION_JSON);\n HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);\n return restTemplate.exchange(trimTrailingSlash(providerBaseUrl) + \"/chat/completions\", HttpMethod.POST, entity, String.class);\n }\n\n private JsonNode extractJsonFromOpenAiCompatibleResponse(String body, String providerName, String operation) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"choices\").path(0).path(\"message\").path(\"content\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(providerName + \" retornou \" + operation + \" vazio\");\n }\n log.info(\"IA processada via {} ({})\", providerName, operation);\n return objectMapper.readTree(content.getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta \" + operation + \" de \" + providerName, e);\n }\n }\n\n private JsonNode extractJsonFromGeminiResponse(String body) {\n try {\n JsonNode root = objectMapper.readTree(body);\n String content = root.path(\"candidates\").path(0).path(\"content\").path(\"parts\").path(0).path(\"text\").asText(\"\");\n if (content.isBlank()) {\n throw new RuntimeException(\"GEMINI retornou JSON vazio\");\n }\n log.info(\"IA processada via GEMINI (json)\");\n return objectMapper.readTree(stripJsonFence(content).getBytes(StandardCharsets.UTF_8));\n } catch (Exception e) {\n throw new RuntimeException(\"Falha ao interpretar resposta JSON de GEMINI\", e);\n }\n }\n\n private static String stripJsonFence(String content) {\n String s = content == null ? \"\" : content.trim();\n if (s.startsWith(\"```\")) {\n s = s.replaceFirst(\"^```(?:json)?\\\\s*\", \"\");\n s = s.replaceFirst(\"\\\\s*```$\", \"\");\n }\n return s.trim();\n }\n\n private void ensureOpenAiCompatibleConfigured(String providerName, String key, String providerBaseUrl) {\n if (!\"OLLAMA\".equals(providerName) && (key == null || key.isBlank())) {\n throw new RuntimeException(providerName + \"_API_KEY não configurada\");\n }\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private void ensureBaseUrl(String providerName, String providerBaseUrl) {\n if (providerBaseUrl == null || providerBaseUrl.isBlank()) {\n throw new RuntimeException(providerName + \"_BASE_URL não configurada\");\n }\n }\n\n private static String trimTrailingSlash(String url) {\n if (url == null) {\n return null;\n }\n String u = url.trim();\n while (u.endsWith(\"/\")) {\n u = u.substring(0, u.length() - 1);\n }\n return u;\n }\n\n /** Config da BD + chave Groq da plataforma (variável consumoesperto.ai.platform-groq-api-key / GROQ_API_KEY). */\n private AiProvidersConfig cfgForAi(Long userId) {\n AiProvidersConfig cfg = aiProvidersConfigService.load(userId);\n aiProvidersConfigService.applyGroqMasterFallback(cfg);\n aiProvidersConfigService.applyOpenaiMasterFallback(cfg);\n return cfg;\n }\n}\n"}}}}