"""Módulo de configuração central do Analytical-Force. Responsabilidades: - Carregar variáveis de ambiente a partir do arquivo ``.env``. - Validar configurações de Salesforce, Turso e provider de modelo. - Permitir o modo ``template`` quando não houver modelo de IA configurado. - Expor a classe :class:`Settings` com type hints. - Nunca imprimir segredos no console/log. Regra crítica do projeto: o agente NÃO depende de APIs comerciais pagas (OpenAI, Anthropic, Groq, Gemini etc.). Apenas modelos locais/públicos são suportados: ``template`` (sem IA), ``ollama`` (local) e ``transformers`` (Hugging Face público). Princípio: este módulo apenas lê e valida configuração. Ele NÃO acessa Salesforce, Turso ou qualquer serviço externo. """ from __future__ import annotations import os from dataclasses import dataclass, field from functools import lru_cache from typing import Any from dotenv import load_dotenv # Carrega o arquivo .env (se existir) para dentro de os.environ. # `override=False` garante que variáveis já presentes no ambiente # (ex.: GitHub Actions Secrets) tenham prioridade sobre o .env local. load_dotenv(override=False) # Provedores de modelo suportados pelo agente. # Apenas modelos locais/públicos gratuitos — sem APIs comerciais pagas. PROVEDORES_MODELO_VALIDOS: tuple[str, ...] = ( "template", # Relatório por regras/template, sem IA. Sempre funciona. "ollama", # Modelo local via API HTTP do Ollama. "transformers", # Modelo público via Hugging Face Transformers (CPU local). "hf_inference", # Modelo hospedado via Hugging Face Inference Providers. ) # Modos de autenticação do Salesforce suportados. # oauth_refresh_token -> OAuth 2.0 Refresh Token (padrão, recomendado). # soap_legacy -> login() SOAP com usuário/senha/token (fallback). SALESFORCE_AUTH_MODES_VALIDOS: tuple[str, ...] = ( "oauth_refresh_token", "soap_legacy", ) def _get_str(nome: str, padrao: str = "") -> str: """Lê uma variável de ambiente como string, com valor padrão. Args: nome: Nome da variável de ambiente. padrao: Valor padrão caso a variável não exista. Returns: Valor da variável (com espaços nas pontas removidos). """ valor = os.environ.get(nome, padrao) return valor.strip() if isinstance(valor, str) else padrao def _get_int(nome: str, padrao: int) -> int: """Lê uma variável de ambiente como inteiro, tolerando valores inválidos.""" bruto = _get_str(nome) if not bruto: return padrao try: return int(bruto) except ValueError: return padrao def _get_float(nome: str, padrao: float) -> float: """Lê uma variável de ambiente como float, tolerando valores inválidos.""" bruto = _get_str(nome) if not bruto: return padrao try: return float(bruto) except ValueError: return padrao def _get_bool(nome: str, padrao: bool) -> bool: """Lê uma variável de ambiente como booleano. Considera verdadeiros: ``1, true, yes, sim, on`` (case-insensitive). """ bruto = _get_str(nome) if not bruto: return padrao return bruto.strip().lower() in {"1", "true", "yes", "sim", "on"} def _normalizar_turso_url(url: str) -> str: """Normaliza a URL do Turso, tolerando erros comuns de digitação. Corrige casos como aspas acidentais, esquema escrito errado (ex.: ``ibsql://`` em vez de ``libsql://``) ou ausência de esquema em um host ``*.turso.io``. Não altera URLs já válidas (libsql/https/file/...). Args: url: Valor bruto de ``TURSO_DATABASE_URL``. Returns: URL normalizada (ou a original quando não há correção segura). """ u = (url or "").strip().strip('"').strip("'") if not u: return u esquemas_ok = ("libsql://", "https://", "http://", "wss://", "ws://", "file:") if u.startswith(esquemas_ok): return u # Sem esquema reconhecido: extrai o "resto" após um eventual "://". resto = u.partition("://")[2] if "://" in u else u # Se aparenta ser um banco Turso remoto, força o esquema libsql. if "turso.io" in resto.lower(): return "libsql://" + resto return u @dataclass(frozen=True) class SalesforceSettings: """Configurações de conexão com o Salesforce. Suporta dois modos de autenticação: - ``oauth_refresh_token`` (padrão): usa OAuth 2.0 com Refresh Token. - ``soap_legacy`` (fallback): usa usuário/senha/security token (SOAP login). Em qualquer modo, o agente opera apenas em leitura (SOQL). """ # Modo de autenticação e parâmetros gerais. auth_mode: str = "oauth_refresh_token" api_version: str = "64.0" read_only_mode: bool = True # OAuth 2.0 Refresh Token (método principal). instance_url: str = "" client_id: str = "" client_secret: str = "" refresh_token: str = "" # Credenciais legadas (somente usadas em soap_legacy). username: str = "" password: str = "" security_token: str = "" domain: str = "login" @property def is_oauth(self) -> bool: """True quando o modo de autenticação é OAuth Refresh Token.""" return self.auth_mode == "oauth_refresh_token" @property def is_configured(self) -> bool: """Indica se há credenciais mínimas para autenticar no Salesforce. A verificação depende do modo de autenticação ativo. """ if self.auth_mode == "oauth_refresh_token": return bool( self.instance_url and self.client_id and self.client_secret and self.refresh_token ) if self.auth_mode == "soap_legacy": return bool(self.username and self.password and self.security_token) return False @dataclass(frozen=True) class TursoSettings: """Configurações de conexão com o banco Turso/libSQL.""" database_url: str auth_token: str @property def is_configured(self) -> bool: """Indica se há dados mínimos para conectar ao Turso. Observação: bancos locais embarcados (arquivo) podem dispensar token, mas o banco principal de produção (Turso remoto) exige ambos. """ return bool(self.database_url) @dataclass(frozen=True) class ModelSettings: """Configurações do provider de modelo (interpretação do relatório). Apenas modelos locais/públicos são suportados. Nenhuma chave de API comercial é lida ou exigida. """ provider: str = "template" enable_ai_interpretation: bool = True # Ollama local (provider padrão). ollama_base_url: str = "http://localhost:11434" ollama_model: str = "llama3.1:8b" # Hugging Face Transformers (opcional). Sem modelo fixo no código. hf_model_repo_id: str = "" hf_device: str = "cpu" # Limite de tokens gerados. Menor = mais rápido (sobretudo em CPU). hf_max_new_tokens: int = 320 # Hugging Face Inference Providers (modelo hospedado, rápido e online). hf_inference_model: str = "" hf_inference_provider: str = "" # vazio = roteamento automático da HF hf_token: str = "" @property def is_template_mode(self) -> bool: """True quando o relatório é gerado sem chamar modelo de IA.""" return self.provider == "template" @property def usa_ia(self) -> bool: """True quando há intenção de usar um modelo de IA (não-template).""" return self.enable_ai_interpretation and self.provider != "template" @dataclass(frozen=True) class RiskSettings: """Limiares configuráveis usados pelo motor de risco.""" lead_max_hours_without_task: int = 24 opportunity_max_days_without_activity: int = 7 conversion_drop_threshold_percent: float = 20.0 pipeline_drop_threshold_percent: float = 15.0 lead_first_task_target_hours: float = 8.0 overdue_tasks_owner_threshold: int = 5 high_value_opportunity_amount: float = 50000.0 # Valor mínimo de oportunidade a considerar na análise de pipeline/risco. # 0 = analisa todas. Use para ignorar oportunidades de baixo valor. opportunity_min_amount: float = 0.0 # Limiar de VALOR DE PRODUTOS (recorrente+pontual) para "alto valor parada". opportunity_product_value_threshold: float = 20000.0 satisfaction_min_score: float = 7.0 @dataclass(frozen=True) class EmailSettings: """Configurações de envio de e-mail (SMTP). Opcional.""" smtp_host: str = "" smtp_port: int = 0 smtp_user: str = "" smtp_password: str = "" recipient_email: str = "" # Gmail API (HTTP/OAuth) — envia por HTTPS, funcionando onde o SMTP é # bloqueado (ex.: Hugging Face Spaces). Preferido quando configurado. gmail_client_id: str = "" gmail_client_secret: str = "" gmail_refresh_token: str = "" gmail_sender: str = "" @property def gmail_api_configured(self) -> bool: """Indica se há credenciais para enviar via Gmail API (HTTP).""" return bool( self.gmail_client_id and self.gmail_client_secret and self.gmail_refresh_token and self.recipient_email ) @property def smtp_configured(self) -> bool: """Indica se há dados mínimos para enviar via SMTP.""" return bool(self.smtp_host and self.smtp_port and self.recipient_email) @property def is_configured(self) -> bool: """Indica se há ALGUM método de envio configurado (Gmail API ou SMTP).""" return self.gmail_api_configured or self.smtp_configured @dataclass(frozen=True) class ClickUpSettings: """Configurações da integração opcional com ClickUp.""" api_token: str = "" list_id: str = "" auto_create: bool = False # Responsável (assignee) das tarefas criadas. Pode-se informar o ID numérico # do usuário (preferencial) ou o e-mail (resolvido via API da lista). assignee_id: str = "" assignee_email: str = "" # Gera um plano de ação por IA (Ollama) na descrição da tarefa, quando # houver provider de IA ativo. Com fallback para descrição só com dados. ai_tasks: bool = True @property def is_configured(self) -> bool: """Indica se há dados mínimos para usar a API do ClickUp.""" return bool(self.api_token and self.list_id) @dataclass(frozen=True) class Settings: """Agregador de todas as configurações do agente. Esta classe é imutável (``frozen=True``) e deve ser obtida via :func:`get_settings`, que aplica cache. """ salesforce: SalesforceSettings turso: TursoSettings model: ModelSettings risk: RiskSettings email: EmailSettings clickup: ClickUpSettings report_timezone: str = "America/Sao_Paulo" custom_fields: dict[str, str] = field(default_factory=dict) # Nomes (trechos) de leads a IGNORAR na análise (ex.: leads de teste). lead_ignore_names: list[str] = field(default_factory=list) # Fontes configuráveis de Satisfação e Cancelamento (objeto + campos). # Permitem ativar esses módulos sem fixar nomes no código (regra 16/17). satisfaction_source: dict[str, Any] = field(default_factory=dict) cancellation_source: dict[str, Any] = field(default_factory=dict) # Fonte configurável de Contratos (objeto de contrato + item de contrato). # Usada pelo módulo de CONSULTA (busca somente leitura) — não pelo pipeline # diário de métricas. Os campos de vínculo (conta/contrato) são opcionais: # sem eles, a consulta funciona em modo degradado (sem relacionamento). contract_source: dict[str, Any] = field(default_factory=dict) # ------------------------------------------------------------------ # Validações # ------------------------------------------------------------------ def validar_salesforce(self) -> list[str]: """Valida configuração do Salesforce conforme o modo de autenticação. Returns: Lista de mensagens de erro (vazia quando válido). """ erros: list[str] = [] sf = self.salesforce if sf.auth_mode not in SALESFORCE_AUTH_MODES_VALIDOS: erros.append( f"SALESFORCE_AUTH_MODE inválido: '{sf.auth_mode}'. " f"Use um de: {', '.join(SALESFORCE_AUTH_MODES_VALIDOS)}." ) return erros if sf.auth_mode == "oauth_refresh_token": # Modo principal: exige instance_url + credenciais OAuth. if not sf.instance_url: erros.append("SALESFORCE_INSTANCE_URL não configurado.") if not sf.client_id: erros.append("SALESFORCE_CLIENT_ID não configurado.") if not sf.client_secret: erros.append("SALESFORCE_CLIENT_SECRET não configurado.") if not sf.refresh_token: erros.append("SALESFORCE_REFRESH_TOKEN não configurado.") else: # soap_legacy if not sf.username: erros.append("SALESFORCE_USERNAME não configurado.") if not sf.password: erros.append("SALESFORCE_PASSWORD não configurado.") if not sf.security_token: erros.append("SALESFORCE_SECURITY_TOKEN não configurado.") return erros def validar_turso(self) -> list[str]: """Valida configuração do Turso (banco principal).""" erros: list[str] = [] if not self.turso.database_url: erros.append("TURSO_DATABASE_URL não configurado.") # Token é obrigatório para Turso remoto; bancos locais (file:) o dispensam. url = self.turso.database_url eh_remoto = url.startswith("libsql://") or url.startswith("https://") if eh_remoto and not self.turso.auth_token: erros.append("TURSO_AUTH_TOKEN não configurado para banco remoto.") return erros def validar_modelo(self) -> list[str]: """Valida configuração do provider de modelo. Aceita somente ``template``, ``ollama`` ou ``transformers``. Nenhuma chave de API comercial é exigida. """ erros: list[str] = [] provider = self.model.provider if provider not in PROVEDORES_MODELO_VALIDOS: erros.append( f"MODEL_PROVIDER inválido: '{provider}'. " f"Use um de: {', '.join(PROVEDORES_MODELO_VALIDOS)}." ) return erros # Modo template não exige nada além do código (sempre funciona). if provider == "ollama" and not self.model.ollama_base_url: erros.append("OLLAMA_BASE_URL não configurado para provider ollama.") elif provider == "transformers" and not self.model.hf_model_repo_id: erros.append( "HF_MODEL_REPO_ID não configurado para provider transformers." ) elif provider == "hf_inference": if not self.model.hf_token: erros.append("HF_TOKEN não configurado para provider hf_inference.") if not self.model.hf_inference_model: erros.append( "HF_INFERENCE_MODEL não configurado para provider hf_inference." ) return erros def validar_tudo(self) -> dict[str, list[str]]: """Executa todas as validações e retorna um relatório consolidado. Returns: Dicionário com listas de erros por área. Áreas sem erro têm lista vazia. """ return { "salesforce": self.validar_salesforce(), "turso": self.validar_turso(), "modelo": self.validar_modelo(), } def resumo_seguro(self) -> dict[str, object]: """Resumo de configuração SEM expor segredos. Útil para registrar em log no início da execução. """ return { "salesforce_configurado": self.salesforce.is_configured, "salesforce_auth_mode": self.salesforce.auth_mode, # instance_url e domínio não são segredos; tokens NUNCA são expostos. "salesforce_instance_url": self.salesforce.instance_url, "salesforce_domain": self.salesforce.domain, "salesforce_read_only": self.salesforce.read_only_mode, "turso_configurado": self.turso.is_configured, "model_provider": self.model.provider, "ai_interpretation": self.model.enable_ai_interpretation, "email_configurado": self.email.is_configured, "clickup_configurado": self.clickup.is_configured, "report_timezone": self.report_timezone, } def _carregar_campos_customizados() -> dict[str, str]: """Carrega mapeamento de campos customizados padrão do Salesforce. Permite sobrescrever via variável de ambiente (ex.: SF_FIELD_FIRST_TASK). Os valores padrão usam os nomes reais da org Penso; campos inexistentes em outra org são ignorados automaticamente (validação por ``describe``). """ return { # Campo do Lead que indica a primeira tarefa registrada. "lead_first_task": _get_str("SF_FIELD_FIRST_TASK", "FirstTask__c"), # Campos customizados de Opportunity (nomes reais da org). "opp_motivo_perda": _get_str("SF_FIELD_OPP_MOTIVO_PERDA", "Motivo_Perda_ganho__c"), # Origem da oportunidade: usa o campo padrão LeadSource (vazio = não custom). "opp_origem": _get_str("SF_FIELD_OPP_ORIGEM", ""), "opp_tipo_venda": _get_str("SF_FIELD_OPP_TIPO_VENDA", "Tipo_venda__c"), "opp_produto": _get_str("SF_FIELD_OPP_PRODUTO", "ProdutoPrincipal__c"), # Campos adicionais úteis (próxima ação, dias sem atividade, GC, valor). "opp_proxima_acao": _get_str("SF_FIELD_OPP_PROXIMA_ACAO", "Proxima_acao__c"), "opp_dias_sem_atividade": _get_str( "SF_FIELD_OPP_DIAS_SEM_ATIVIDADE", "OppDiasSemAtividade__c" ), "opp_gc_nome": _get_str("SF_FIELD_OPP_GC_NOME", "GC_Nome__c"), "opp_valor_mensal": _get_str("SF_FIELD_OPP_VALOR_MENSAL", "Valor_Liquido_Mensal__c"), "opp_valor_mensal_produtos": _get_str( "SF_FIELD_OPP_VALOR_MENSAL_PRODUTOS", "Valor_Mensal_Produtos__c" ), "opp_valor_pontual_produtos": _get_str( "SF_FIELD_OPP_VALOR_PONTUAL_PRODUTOS", "Valor_Pontual_Produtos__c" ), } def _carregar_fonte_satisfacao() -> dict[str, Any]: """Monta a configuração da fonte de Satisfação (objeto + campos). Padrões alinhados ao objeto ``Satisfacao__c`` da org Penso. Como o campo de sentimento é categórico, o extrator deriva uma nota numérica na coluna ``SentimentScore`` (Verde=10, Amarelo=5, Vermelho=0), usada como ``score``. """ sentimento = _get_str("SF_SAT_SENTIMENT_FIELD", "Sentimento__c") return { "object": _get_str("SF_SAT_OBJECT", "Satisfacao__c"), "date_field": _get_str("SF_SAT_DATE_FIELD", "Reply_Date__c"), "sentiment_field": sentimento, "fields": [ "Id", "Name", "Conta_Nome__c", sentimento, "Sentimento_Nivel__c", "CreatedDate", "LastModifiedDate", "Area_insatisfacao__c", "area_de_insatisfacao_motivador__c", "Motivador__c", "Motivador_Outros__c", "Resumo__c", "Ticket_Atual__c", "Owner_Nome__c", "Gerente_Comercial__c", "Tipo_de_Pesquisa__c", "Status__c", "Tier__c", ], "field_mapping": { # Coluna numérica derivada do sentimento (criada no extrator). "score": "SentimentScore", "reason": _get_str("SF_SAT_REASON_FIELD", "area_de_insatisfacao_motivador__c"), "comment": _get_str("SF_SAT_COMMENT_FIELD", "Resumo__c"), "negative_threshold": _get_float("SF_SAT_NEGATIVE_THRESHOLD", 5.0), }, # Escala de conversão do sentimento (chave = trecho em minúsculas). "sentiment_scale": {"verde": 10.0, "amarelo": 5.0, "vermelho": 0.0}, } def _carregar_fonte_cancelamento() -> dict[str, Any]: """Monta a configuração da fonte de Cancelamento (objeto + campos). Padrão: oportunidades com data de cancelamento no dia (``DATA_CANCELAMENTO__c``). O impacto em MRR usa o valor líquido mensal; ARR é derivado (×12) na métrica. """ return { "object": _get_str("SF_CANC_OBJECT", "Opportunity"), "date_field": _get_str("SF_CANC_DATE_FIELD", "DATA_CANCELAMENTO__c"), "fields": [ "Id", "Name", "Conta_Raz_o_Social__c", "Conta_CNPJ__c", "DATA_CANCELAMENTO__c", "VALOR_CANCELADO__c", "Valor_Liquido_Mensal__c", "Valor_Cancelado_12_Meses__c", "Motivo_Cancelamento__c", "Tipo_Cancelamento__c", "ProdutoPrincipal__c", "GC_Nome__c", "OwnerId", "StageName", ], "field_mapping": { "mrr": _get_str("SF_CANC_MRR_FIELD", "Valor_Liquido_Mensal__c"), "reason": _get_str("SF_CANC_REASON_FIELD", "Motivo_Cancelamento__c"), "product": _get_str("SF_CANC_PRODUCT_FIELD", "ProdutoPrincipal__c"), "owner": _get_str("SF_CANC_OWNER_FIELD", "GC_Nome__c"), }, } def _carregar_fonte_contrato() -> dict[str, Any]: """Monta a configuração da fonte de Contratos (objeto + vínculos + reajuste). Diferente de satisfação/cancelamento, os campos de objeto/vínculo desta fonte alimentam o módulo de CONSULTA (busca somente leitura de contratos, itens de contrato e clientes). Os campos de vínculo (``account_field`` e ``item_parent_field``) são o NOME DE API do campo de lookup na org; como o projeto não pode inventar nomes de campo (regra 3), eles ficam vazios por padrão — a consulta funciona em modo degradado (sem relacionamento de conta/itens) até que sejam configurados via ``.env`` ou pelo painel. Padrões de objeto alinhados à org Penso (``Contrato_oPT__c`` e ``Item_do_Contrato__c``); ajuste via env se sua org usar outros nomes. Já o bloco de REAJUSTE (``value_field``/``previous_value_field``/ ``readjustment_field``/``readjustment_date_field``) alimenta o pipeline diário (relatório/e-mail): módulo configurável (regra 16/17) — vazio por padrão, pois os nomes reais desses campos na org Penso não são conhecidos e não podem ser adivinhados. Sem essa configuração, a análise de reajuste permanece desativada (sem quebrar o restante do relatório). """ return { "object": _get_str("SF_CONTRACT_OBJECT", "Contrato_oPT__c"), "item_object": _get_str("SF_CONTRACT_ITEM_OBJECT", "Item_do_Contrato__c"), # Lookup do Contrato -> Account (nome de API do campo, ex.: "Conta__c"). # Vazio = busca de contratos por cliente fica desativada. "account_field": _get_str("SF_CONTRACT_ACCOUNT_FIELD", ""), # Campo de relacionamento para exibir o NOME da conta (ex.: "Conta__r.Name"). # Vazio = listagens de contrato não mostram o nome do cliente. "account_name_field": _get_str("SF_CONTRACT_ACCOUNT_NAME_FIELD", ""), # Lookup do Item de Contrato -> Contrato (nome de API do campo). # Vazio = a tela de contrato não lista os itens vinculados. "item_parent_field": _get_str("SF_CONTRACT_ITEM_PARENT_FIELD", ""), # ---- Módulo configurável de REAJUSTE (vazio = desativado) ---- # Campo com o valor ATUAL do contrato (ex.: "Valor_Atual__c"). "value_field": _get_str("SF_CONTRACT_VALUE_FIELD", ""), # Campo com o valor ANTERIOR ao reajuste (ex.: "Valor_Anterior__c"). "previous_value_field": _get_str("SF_CONTRACT_PREVIOUS_VALUE_FIELD", ""), # Campo com o valor de reajuste já informado no contrato (opcional). # Se vazio, o reajuste é ESTIMADO como (valor atual - valor anterior). "readjustment_field": _get_str("SF_CONTRACT_READJUSTMENT_FIELD", ""), # Unidade do campo acima: "amount" (R$, padrão) ou "percent" (ex.: um # campo percent(16,2) como "4.39" = 4,39%). Controla como o valor # informado é comparado ao delta calculado por Python. "readjustment_field_unit": ( _get_str("SF_CONTRACT_READJUSTMENT_FIELD_UNIT", "amount") or "amount" ).strip().lower(), # Campo de data em que o reajuste foi aplicado (usado para filtrar # "reajustes do mês"). Sem ele, o módulo de reajuste fica desativado. "readjustment_date_field": _get_str("SF_CONTRACT_READJUSTMENT_DATE_FIELD", ""), } @lru_cache(maxsize=1) def get_settings() -> Settings: """Constrói (uma única vez) e retorna as configurações do agente. O resultado é cacheado para evitar releitura do ambiente a cada chamada. Returns: Instância imutável de :class:`Settings`. """ salesforce = SalesforceSettings( # Modo padrão = OAuth Refresh Token (recomendado). auth_mode=( _get_str("SALESFORCE_AUTH_MODE", "oauth_refresh_token") or "oauth_refresh_token" ).lower(), api_version=_get_str("SALESFORCE_API_VERSION", "64.0") or "64.0", read_only_mode=_get_bool("SALESFORCE_READ_ONLY_MODE", True), # OAuth Refresh Token. instance_url=_get_str("SALESFORCE_INSTANCE_URL").rstrip("/"), client_id=_get_str("SALESFORCE_CLIENT_ID"), client_secret=_get_str("SALESFORCE_CLIENT_SECRET"), refresh_token=_get_str("SALESFORCE_REFRESH_TOKEN"), # Credenciais legadas (fallback soap_legacy). username=_get_str("SALESFORCE_USERNAME"), password=_get_str("SALESFORCE_PASSWORD"), security_token=_get_str("SALESFORCE_SECURITY_TOKEN"), domain=_get_str("SALESFORCE_DOMAIN", "login") or "login", ) turso = TursoSettings( database_url=_normalizar_turso_url(_get_str("TURSO_DATABASE_URL")), auth_token=_get_str("TURSO_AUTH_TOKEN"), ) model = ModelSettings( # Padrão "template" garante MVP funcional sem nenhum modelo instalado. provider=(_get_str("MODEL_PROVIDER", "template") or "template").lower(), enable_ai_interpretation=_get_bool("ENABLE_AI_INTERPRETATION", True), ollama_base_url=_get_str("OLLAMA_BASE_URL", "http://localhost:11434"), ollama_model=_get_str("OLLAMA_MODEL", "llama3.1:8b"), hf_model_repo_id=_get_str("HF_MODEL_REPO_ID"), hf_device=_get_str("HF_DEVICE", "cpu") or "cpu", hf_max_new_tokens=_get_int("HF_MAX_NEW_TOKENS", 320), hf_inference_model=_get_str("HF_INFERENCE_MODEL"), hf_inference_provider=_get_str("HF_INFERENCE_PROVIDER"), hf_token=_get_str("HF_TOKEN"), ) risk = RiskSettings( lead_max_hours_without_task=_get_int("LEAD_MAX_HOURS_WITHOUT_TASK", 24), opportunity_max_days_without_activity=_get_int( "OPPORTUNITY_MAX_DAYS_WITHOUT_ACTIVITY", 7 ), conversion_drop_threshold_percent=_get_float( "CONVERSION_DROP_THRESHOLD_PERCENT", 20.0 ), pipeline_drop_threshold_percent=_get_float( "PIPELINE_DROP_THRESHOLD_PERCENT", 15.0 ), lead_first_task_target_hours=_get_float("LEAD_FIRST_TASK_TARGET_HOURS", 8.0), overdue_tasks_owner_threshold=_get_int("OVERDUE_TASKS_OWNER_THRESHOLD", 5), high_value_opportunity_amount=_get_float( "HIGH_VALUE_OPPORTUNITY_AMOUNT", 50000.0 ), opportunity_min_amount=_get_float("OPPORTUNITY_MIN_AMOUNT", 0.0), opportunity_product_value_threshold=_get_float( "OPPORTUNITY_PRODUCT_VALUE_THRESHOLD", 20000.0 ), satisfaction_min_score=_get_float("SATISFACTION_MIN_SCORE", 7.0), ) email = EmailSettings( smtp_host=_get_str("SMTP_HOST"), smtp_port=_get_int("SMTP_PORT", 0), smtp_user=_get_str("SMTP_USER"), smtp_password=_get_str("SMTP_PASSWORD"), recipient_email=_get_str("REPORT_RECIPIENT_EMAIL"), gmail_client_id=_get_str("GMAIL_CLIENT_ID"), gmail_client_secret=_get_str("GMAIL_CLIENT_SECRET"), gmail_refresh_token=_get_str("GMAIL_REFRESH_TOKEN"), gmail_sender=_get_str("GMAIL_SENDER"), ) clickup = ClickUpSettings( # Remove aspas acidentais coladas no valor (causa comum de 401 no ClickUp). api_token=_get_str("CLICKUP_API_TOKEN").strip('"').strip("'"), list_id=_get_str("CLICKUP_LIST_ID").strip('"').strip("'"), auto_create=_get_bool("ENABLE_CLICKUP_AUTO_CREATE", False), assignee_id=_get_str("CLICKUP_ASSIGNEE_ID"), assignee_email=_get_str("CLICKUP_ASSIGNEE_EMAIL"), ai_tasks=_get_bool("CLICKUP_AI_TASKS", True), ) return Settings( salesforce=salesforce, turso=turso, model=model, risk=risk, email=email, clickup=clickup, report_timezone=_get_str("REPORT_TIMEZONE", "America/Sao_Paulo") or "America/Sao_Paulo", custom_fields=_carregar_campos_customizados(), lead_ignore_names=[ n.strip().lower() for n in _get_str("SF_IGNORE_LEAD_NAMES", "teste,test").split(",") if n.strip() ], satisfaction_source=_carregar_fonte_satisfacao(), cancellation_source=_carregar_fonte_cancelamento(), contract_source=_carregar_fonte_contrato(), )