Spaces:
Running
Running
| """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 | |
| 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" | |
| def is_oauth(self) -> bool: | |
| """True quando o modo de autenticação é OAuth Refresh Token.""" | |
| return self.auth_mode == "oauth_refresh_token" | |
| 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 | |
| class TursoSettings: | |
| """Configurações de conexão com o banco Turso/libSQL.""" | |
| database_url: str | |
| auth_token: str | |
| 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) | |
| 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 = "" | |
| def is_template_mode(self) -> bool: | |
| """True quando o relatório é gerado sem chamar modelo de IA.""" | |
| return self.provider == "template" | |
| 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" | |
| 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 | |
| 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 = "" | |
| 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 | |
| ) | |
| 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) | |
| 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 | |
| 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 | |
| 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) | |
| 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", ""), | |
| } | |
| 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(), | |
| ) | |