1 line
3.0 MiB
Plaintext
1 line
3.0 MiB
Plaintext
{"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"}}}} |