diff --git a/README.md b/README.md index 76dc5ad..45b2dbc 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ docs/ decisões de modelagem (DECISOES.md), glossário, BPMN, resultados genai/ formato JSON, regras, normas, prompts e código do parecer notebooks/ EDA e etapas do modelo (coortes, variáveis, XGBoost, LGD, margem) src/api/ API FastAPI -src/app/ painel Streamlit +src/app/ painel Streamlit (main.py = telas, estilo.css = visual, componentes.py = blocos HTML) src/dados/ cargas do Bondora e das séries de juros src/modelos/ modelo de risco e juro justo (prever()) modelos_salvos/ pacote do modelo (.joblib, fora do Git) @@ -62,11 +62,11 @@ tests/ testes Pré-requisitos: Git, Python 3.14 e Docker Desktop. -**Atalho (Windows):** com o banco já carregado (passos 2 a 5 abaixo, uma vez), use o `iniciar.ps1`: +**Atalho (Windows):** clique duplo no `iniciar.bat` (ou `.\iniciar.ps1`). Num PC novo basta Docker Desktop, `.env`, o `.joblib` do modelo e os 4 CSVs de custo do dinheiro em `data/raw/` (para a referência do BC): veja [`docs/RODAR_EM_QUALQUER_PC.md`](docs/RODAR_EM_QUALQUER_PC.md). A base do Bondora só é necessária para refazer os dados e o modelo (passos 2 a 5 abaixo). | Comando | O que faz | | --- | --- | -| `.\iniciar.ps1` | confere `.env` e modelo, sobe o banco, aplica a tabela de decisões, indexa as normas (se faltar), sobe API e painel e abre o navegador. Constrói a imagem só se ela não existir. | +| `.\iniciar.ps1` | confere portas, `.env` e modelo, sobe o banco, aplica a tabela de decisões, indexa as normas e carrega a referência do BC (se faltarem), sobe API e painel e abre o navegador. Constrói a imagem só se ela não existir. | | `.\iniciar.ps1 -Build` | o mesmo, reconstruindo a imagem (mudou código ou requirements) | | `.\iniciar.ps1 -Parar` | para tudo; os dados do banco ficam guardados | @@ -84,7 +84,7 @@ O primeiro build leva alguns minutos (PyTorch); os seguintes reaproveitam o cach 2. **Dados.** Todos os arquivos ficam numa pasta compartilhada do Drive, fora do Git (o repositório é público). Peça o link ao grupo e coloque os arquivos em `data/raw/`, com estes nomes: - `LoanData_Bondora.csv` (confira o SHA-256 em `DATA.md`) - - `ECB_Euribor_Serie-Mensal-12-Meses_csv.csv` e `ECB_Euribor_Serie-Mensal-3-Meses.csv` + - `ECB_Euribor_Serie-Mensal-12-Meses.csv` (o download do BCE às vezes vem como `..._csv.csv`: os dois nomes funcionam) e `ECB_Euribor_Serie-Mensal-3-Meses.csv` - `BC-Selic_Acumulada_Mes_Anuzalizada_base252.csv` - `BC-Meta_Selic_Mensal.csv` (opcional, só conferência) - `bcdata.sgs.20742.csv` (opcional, referência de mercado do BC) @@ -143,7 +143,8 @@ O primeiro build leva alguns minutos (PyTorch); os seguintes reaproveitam o cach - [`genai/FORMATO_JSON.md`](genai/FORMATO_JSON.md) e [`genai/REGRAS_PARECER.md`](genai/REGRAS_PARECER.md): contrato e regras do parecer. - [`genai/avaliacao/`](genai/avaliacao/CRITERIOS.md): critérios, os 20 casos de avaliação e os resultados do parecer (prompts v2 a v4), com a revisão humana da v4. - [`docs/ROTEIRO_DEMO.md`](docs/ROTEIRO_DEMO.md): roteiro da demo, plano B e perguntas prováveis. -- [`infra/`](infra/README.md): o mesmo ambiente em Terraform (provider Docker, local, sem custo). +- [`infra/`](infra/README.md): Terraform que cria na Google Cloud uma VM rodando o mesmo Docker Compose (para a demo na nuvem). +- [`docs/RODAR_EM_QUALQUER_PC.md`](docs/RODAR_EM_QUALQUER_PC.md): como rodar em qualquer PC da equipe e a diferença entre Docker e Terraform. - [`skills/`](skills/README.md): guia do projeto por setor (estrutura, ambiente, dados, modelo, juro justo, parecer, API, painel, governança). ## Governança e ética @@ -168,6 +169,6 @@ O primeiro build leva alguns minutos (PyTorch); os seguintes reaproveitam o cach Cada frente registra aqui como usou assistentes de IA (agentic coding) e como revisou o resultado. - **Dados:** os scripts de carga, os esquemas SQL e a documentação foram escritos com apoio do Claude e revisados e testados pelo responsável da frente. -- **App e infraestrutura:** a integração da API com o `prever()`, as telas do painel e os testes foram feitos com apoio do Claude, a partir dos protótipos do grupo e dos contratos (`FORMATO_JSON.md`, `REGRAS_PARECER.md`). A responsável revisou o código e conferiu o resultado com testes automáticos e rodando as telas. +- **App e infraestrutura:** a integração da API com o `prever()`, as telas do painel e os testes foram feitos com apoio do Claude, a partir dos protótipos do grupo e dos contratos (`FORMATO_JSON.md`, `REGRAS_PARECER.md`). A responsável revisou o código e conferiu o resultado com testes automáticos e rodando as telas. O formulário com etapas travadas, a separação do visual em `estilo.css` e os scripts `iniciar` seguiram o mesmo processo. - **Modelo:** a completar pela frente. - **GenAI:** o código do parecer, os prompts e os documentos da frente foram escritos com apoio do Claude, guiado pelos guias da frente em `skills/regras_negocio/` ([`normas_rag`](skills/regras_negocio/normas_rag/SKILL.md), [`parecer_genai`](skills/regras_negocio/parecer_genai/SKILL.md) e [`avaliacao_parecer`](skills/regras_negocio/avaliacao_parecer/SKILL.md)) e pelos contratos (`FORMATO_JSON.md`, `REGRAS_PARECER.md`). As propostas maiores (template, cadeia em LangChain, prompts v3 e v4) também passaram pela revisão de uma segunda IA. A responsável leu cada mudança antes de aplicá-la e conferiu o resultado com os testes automáticos (`tests/genai/`), a avaliação dos 20 casos e a revisão humana dos pareceres (`genai/avaliacao/`). diff --git a/docs/RODAR_EM_QUALQUER_PC.md b/docs/RODAR_EM_QUALQUER_PC.md new file mode 100644 index 0000000..0210ee5 --- /dev/null +++ b/docs/RODAR_EM_QUALQUER_PC.md @@ -0,0 +1,69 @@ +# Rodar o Auditor em qualquer PC da equipe + +## Docker e Terraform: para que serve cada um + +| | Docker Compose (`iniciar.bat`) | Terraform (`infra/`) | +| --- | --- | --- | +| O que faz | sobe banco, API e painel **no seu PC** | cria **uma máquina na Google Cloud** que roda o mesmo Docker Compose | +| Quando usar | no dia a dia, nos testes e como plano B da demo | para mostrar o sistema na nuvem e a "infraestrutura como código" | +| Custo | nenhum | gasta o crédito grátis da Google enquanto a máquina existir | +| Guia | este arquivo | [`infra/README.md`](../infra/README.md) | + +**Quem roda a aplicação é sempre o Docker.** O `docker-compose.yml` descreve os 3 containers. +O Terraform não substitui o Docker: ele cria o *computador* (rede, IP, firewall e VM) onde o Docker vai rodar. +Em uma frase: **"Docker empacota a aplicação; Terraform cria a infraestrutura onde ela roda."** + +## 1. Uma vez por PC + +| O quê | De onde | +| --- | --- | +| Docker Desktop | docker.com → *Download Docker Desktop* (Windows: aceite o WSL 2). Abra e espere ficar verde. | +| Git | git-scm.com | +| Projeto | `git clone https://github.com/yaMimis/Auditor-Project.git` | +| `.env` | peça à Yasmin (tem a `GOOGLE_API_KEY` e a `CLIENTE_ID_SAL`). Nunca vai para o GitHub. | +| `modelos_salvos\pacote_prever_v1.joblib` | peça ao Samuel (Drive) | +| 4 CSVs de custo do dinheiro em `data\raw\` | peça ao Marcos (Drive), nomes abaixo | + +Os 4 CSVs (são pequenos, séries mensais do Banco Central e do Banco Central Europeu): + +- `ECB_Euribor_Serie-Mensal-12-Meses_csv.csv` (ou sem o `_csv`) +- `ECB_Euribor_Serie-Mensal-3-Meses.csv` +- `BC-Selic_Acumulada_Mes_Anuzalizada_base252.csv` +- `bcdata_sgs_20742.csv` (ou `bcdata.sgs.20742.csv`): a taxa média do crédito pessoal, que vira a "referência de mercado" no parecer + +Na primeira vez, o `iniciar` carrega esses arquivos no banco sozinho. Os mesmos 4 arquivos vão para a nuvem (passo 14 do `infra/README.md`). + +Os arquivos da aba "Auditoria da carteira" (`docs\resultados\`, gerados pelo Samuel) já vêm com o `git clone`. + +A base grande do Bondora (`LoanData`) **não** é necessária para rodar o sistema. Ela só serve para refazer +os dados e o modelo (passos 2 a 5 do README). + +## 2. Todo dia + +**Clique duplo no `iniciar.bat`** (ou `.\iniciar.ps1` no PowerShell). Ele: + +1. confere se o Docker está ligado e se as portas 5432, 8000 e 8501 estão livres; +2. confere o `.env` e o modelo; +3. sobe o banco e aplica a tabela de decisões; +4. constrói a imagem só se ela não existir; +5. indexa as normas e carrega a referência do BC, se faltarem; +6. sobe API e painel e abre http://localhost:8501. + +| Situação | Comando | +| --- | --- | +| Subir | `iniciar.bat` ou `.\iniciar.ps1` | +| Veio código novo no `git pull` | `.\iniciar.ps1 -Build` | +| Desligar (os dados ficam) | `.\iniciar.ps1 -Parar` | +| Ver o que deu errado | `docker compose logs api --tail 40` | + +## 3. Problemas comuns + +| Mensagem | O que fazer | +| --- | --- | +| "O Docker não está rodando" | Abra o Docker Desktop e espere ficar verde | +| "A porta X está com o container Y" | `docker stop Y` (o script mostra o comando) e rode de novo | +| "A porta X está ocupada pelo programa Y" | Feche o programa (o script mostra o comando) | +| "Sem a referência de mercado do BC" | Coloque os 4 CSVs em `data\raw\` e rode de novo | +| "não está assinado" / ExecutionPolicy | Use o `iniciar.bat`, ou antes: `Set-ExecutionPolicy Bypass -Scope Process` | +| "Falta modelos_salvos\...joblib" | Peça o arquivo ao Samuel; até lá, use "Caso de exemplo" | +| API demora na 1ª vez | Normal: ela baixa o modelo de busca nas normas (~0,5 GB) uma única vez | diff --git a/infra/outputs.tf b/infra/outputs.tf index 5735e1e..9e8c78d 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -34,8 +34,8 @@ output "comando_esperar_vm" { } output "comando_enviar_arquivos" { - description = "Envia o modelo e os CSVs de custo do dinheiro, que não estão no Git" - value = "scp -i ${local.chave} modelos_salvos/pacote_prever_v1.joblib data/raw/ECB_Euribor_Serie-Mensal-12-Meses_csv.csv data/raw/ECB_Euribor_Serie-Mensal-3-Meses.csv data/raw/BC-Selic_Acumulada_Mes_Anuzalizada_base252.csv data/raw/bcdata_sgs_20742.csv ${var.usuario}@${local.ip}:~/envio/" + description = "Envia o modelo e os CSVs de custo do dinheiro, que não estão no Git (nomes como estão no Drive do grupo)" + value = "scp -i ${local.chave} modelos_salvos/pacote_prever_v1.joblib data/raw/ECB_Euribor_Serie-Mensal-12-Meses.csv data/raw/ECB_Euribor_Serie-Mensal-3-Meses.csv data/raw/BC-Selic_Acumulada_Mes_Anuzalizada_base252.csv data/raw/bcdata.sgs.20742.csv ${var.usuario}@${local.ip}:~/envio/" } output "comando_completar" { diff --git a/iniciar.bat b/iniciar.bat new file mode 100644 index 0000000..200100e --- /dev/null +++ b/iniciar.bat @@ -0,0 +1,6 @@ +@echo off +rem Clique duplo para subir o Auditor de Preco Justo (banco, API e painel). +rem Mesmo efeito de .\iniciar.ps1; repassa opcoes, ex.: iniciar.bat -Build +cd /d "%~dp0" +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0iniciar.ps1" %* +pause diff --git a/iniciar.ps1 b/iniciar.ps1 index a594366..c40862c 100644 --- a/iniciar.ps1 +++ b/iniciar.ps1 @@ -5,6 +5,7 @@ # .\iniciar.ps1 -Build reconstroi a imagem antes (mudou codigo ou requirements) # .\iniciar.ps1 -Parar para tudo (os dados do banco continuam no volume) # .\iniciar.ps1 -SemNavegador nao abre o navegador no final +# Ou de clique duplo no iniciar.bat (mesmo efeito, sem mexer na ExecutionPolicy). # Se o Windows bloquear o script, rode antes, na mesma janela: # Set-ExecutionPolicy Bypass -Scope Process # @@ -52,6 +53,21 @@ if ($Parar) { exit 0 } +# Portas livres? (5432 banco, 8000 API, 8501 painel). Outro container ou programa nelas impede a subida. +$nossos = @("auditor_postgres", "auditor_api", "auditor_streamlit") +$programas_docker = @("com.docker.backend", "wslrelay", "vpnkit", "com.docker.proxy", "dockerd", "docker") +foreach ($porta in 5432, 8000, 8501) { + $outro = docker ps --filter "publish=$porta" --format "{{.Names}}" | Where-Object { $nossos -notcontains $_ } + if ($outro) { Parar "A porta $porta esta com o container '$outro'. Pare com: docker stop $outro e rode de novo." } + $con = Get-NetTCPConnection -LocalPort $porta -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($con) { + $proc = Get-Process -Id $con.OwningProcess -ErrorAction SilentlyContinue + if ($proc -and ($programas_docker -notcontains $proc.ProcessName)) { + Parar "A porta $porta esta ocupada pelo programa '$($proc.ProcessName)'. Feche-o com: Stop-Process -Id $($proc.Id) e rode de novo." + } + } +} + # 1. .env (fica fora do Git): cria um modelo se nao existir, sem sobrescrever o seu Passo "1/6 Conferindo o .env e o modelo" if (-not (Test-Path ".env")) { @@ -93,10 +109,6 @@ docker exec auditor_postgres psql -q -U postgres -d auditor_db -v ON_ERROR_STOP= if ($LASTEXITCODE -ne 0) { Parar "Erro ao aplicar db\05_decisoes_analista.sql. Rode o comando a mao para ver a mensagem." } Ok "tabela decisao_analista" -$contratos = docker exec auditor_postgres psql -tA -U postgres -d auditor_db -c "SELECT count(*) FROM emprestimo" 2>$null -if ("$contratos".Trim() -eq "0") { - Aviso "O banco esta sem os contratos (cargas do README, passo 5). O painel funciona, mas sem a referencia de mercado do BC." -} # 4. Imagem: so constroi se nao existir ou se pedirem -Build Passo "4/6 Imagem da aplicacao" @@ -110,8 +122,8 @@ if ($Build -or $LASTEXITCODE -ne 0) { Ok "imagem ja existe (use -Build se mudou codigo ou requirements)" } -# 5. Normas do parecer no pgvector (dentro do container: nao precisa de Python local) -Passo "5/6 Normas do parecer" +# 5. Normas do parecer no pgvector e referencia de mercado do BC (dentro do container: sem Python local) +Passo "5/6 Normas do parecer e referencia do BC" $trechos = docker exec auditor_postgres psql -tA -U postgres -d auditor_db -c "SELECT count(*) FROM trechos_normas" 2>$null if ("$trechos".Trim() -match "^[1-9]") { Ok "normas ja indexadas ($("$trechos".Trim()) trechos)" @@ -121,6 +133,23 @@ if ("$trechos".Trim() -match "^[1-9]") { else { Ok "normas indexadas" } } +# Referencia do BC: series mensais de 4 CSVs pequenos em data\raw (a base do Bondora nao e necessaria). +# Mesma carga que o completar.sh faz na VM da nuvem (infra/cloud-init.yaml). +$meses_bc = docker exec auditor_postgres psql -tA -U postgres -d auditor_db -c "SELECT count(*) FROM bc_credito_pessoal_mensal" 2>$null +if ("$meses_bc".Trim() -match "^[1-9]") { + Ok "referencia do BC ja carregada ($("$meses_bc".Trim()) meses)" +} elseif (-not ((Test-Path "data\raw\bcdata.sgs.20742.csv") -or (Test-Path "data\raw\bcdata_sgs_20742.csv"))) { + Aviso "Sem a referencia de mercado do BC: falta data\raw\bcdata.sgs.20742.csv (baixe em https://api.bcb.gov.br/dados/serie/bcdata.sgs.20742/dados?formato=csv) e rode de novo." +} elseif ((Test-Path "data\raw\ECB_Euribor_Serie-Mensal-3-Meses.csv") -and + (Test-Path "data\raw\BC-Selic_Acumulada_Mes_Anuzalizada_base252.csv")) { + $pasta_raw = (Resolve-Path "data\raw").Path + docker compose run --rm --no-deps -v "${pasta_raw}:/app/data/raw:ro" api python src/dados/carga_macro.py --ddl db/02_schema_macro.sql + if ($LASTEXITCODE -ne 0) { Aviso "A carga das series do BC falhou (veja a mensagem acima). O painel funciona sem a referencia de mercado." } + else { Ok "Euribor, Selic e taxa do BC carregadas" } +} else { + Aviso "Sem a referencia de mercado do BC. Coloque em data\raw os CSVs da Euribor (12 e 3 meses) e da Selic (peca ao Marcos) e rode de novo." +} + # 6. API e painel em segundo plano; espera a API ficar saudavel Passo "6/6 Subindo a API e o painel" docker compose up -d api streamlit diff --git a/requirements.txt b/requirements.txt index 5b41d0a..2b98d4f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ requests>=2.31.0 python-dotenv # Painel -streamlit>=1.37.0 +streamlit>=1.44.0 # st.container(key=...) e ícones nos botões (etapas do formulário) # Banco de Dados sqlalchemy>=2.0.0 diff --git a/skills/regras_negocio/painel/SKILL.md b/skills/regras_negocio/painel/SKILL.md index 175c8e8..7d0bc2a 100644 --- a/skills/regras_negocio/painel/SKILL.md +++ b/skills/regras_negocio/painel/SKILL.md @@ -5,7 +5,7 @@ description: Use para construir ou alterar o painel Streamlit do Auditor de Pre # Painel do analista (Streamlit) -> Leia antes: `regras_negocio/governanca`, `regras_negocio/contratos_dados`, `regras_negocio/api`. Dono: Yasmin. Código: `src/app/main.py` (tela), `src/app/componentes.py` (formatação e HTML, sem Streamlit), `src/app/carteira.py` (leitura e resumo do lote); testes `tests/app/`. +> Leia antes: `regras_negocio/governanca`, `regras_negocio/contratos_dados`, `regras_negocio/api`. Dono: Yasmin. Código: `src/app/main.py` (telas), `src/app/estilo.css` (todo o CSS; o `main.py` só injeta), `src/app/componentes.py` (formatação e HTML, sem Streamlit), `src/app/carteira.py` (leitura e resumo do lote); testes `tests/app/`. ## Objetivo @@ -28,11 +28,13 @@ Dar ao analista, em uma tela, **o risco, o preço, o porquê e o parecer** de ca ### Aba 1 — Revisão de contrato - Origem: **Novo contrato (formulário)** ou **Caso de exemplo**: os 8 casos reais de `docs/casos_de_teste.json` (reserva: `docs/exemplos_prever.json`), abertos com parecer via `/parecer`. -- **Formulário em 3 etapas**, com "Confirmo que revisei" por etapa, barra de progresso REVISADO/PENDENTE e resumo do pedido: +- **Formulário em 3 etapas travadas**, com "Confirmo que revisei" por etapa, barra de progresso REVISADO/PENDENTE e resumo do pedido: + - uma etapa só abre com todas as anteriores revisadas (🔒 e botão desabilitado); voltar é sempre permitido; "Avançar para …" só com a caixa marcada; + - mudar um campo depois de revisado **desmarca** a revisão daquela etapa; desmarcar uma etapa anterior leva de volta à primeira pendente; - 01 Dados pessoais: idade, gênero (0 Masculino · 1 Feminino · 2 Não informado; só auditoria), país (EE/FI/ES), renda mensal (€) - 02 Empréstimo: ID, valor (€), juro cobrado (% a.a.), prazo (3–60 meses), data de concessão (01/01/2009 até hoje; padrão hoje) - 03 Dívidas & histórico: total de dívidas (€); os demais campos seguem "sem informação" - - Botão "⚖️ EXECUTAR AUDITORIA DE PREÇO". + - Botão "⚖️ EXECUTAR AUDITORIA DE PREÇO" **desabilitado até as 3 caixas**, com o aviso "Falta revisar: …"; o código também confere antes de chamar a API. - **Resultado:** - cabeçalho: contrato, país, data, valor, prazo, etiqueta da situação, chips ("Custo do dinheiro: Euribor da época", "Parecer: análise hipotética pela lei brasileira", versão do modelo, "Caso real da base", "Exemplo ilustrativo" ou "Aberto da carteira"); - 4 cartões: risco de inadimplência (PD, IC 95% quando houver, faixa) · juro cobrado · juro justo com margem · situação do preço (cor da situação, pp além da margem); @@ -67,6 +69,9 @@ Cartões (decisões, diferentes da recomendação, encaminhadas ao compliance), | Acima do justo | `#e8a462` · Abaixo `#c3a6f0` · Dentro `#8cc7a1` · Revisão manual `#9aa5b5` | | Aumenta / reduz risco | `#d9825b` / `#7fa7d9` | | Fontes | títulos Playfair Display; texto DM Sans | +| Abas principais (Revisão · Carteira · Registro) | cartões de 104 px, gradiente escuro; aba ativa com gradiente dourado `#c89a58 → #e4bd7d` e texto `#171315` | +| Etapas do formulário (Dados pessoais · Empréstimo · Dívidas) | abas de texto pequenas, alinhadas à esquerda, sem caixa: cinza `#9aa5b5`, hover só muda a cor, ativa dourada `#e4bd7d` com traço de 2 px `#c89a58`; travada com opacidade 38% | +| Ícones | nada de emoji: ícones de linha brancos (`componentes.icone()`, SVG) no HTML e Material (`icon=":material/...:"`) nos botões; etapa revisada `check_circle`, travada `lock` | Tema do Streamlit (`.streamlit/config.toml`): `base="dark"`, `primaryColor="#D4AD70"`, `backgroundColor="#080D16"`, `secondaryBackgroundColor="#101A2A"`, `textColor="#F1F0EB"`, `headless=true`, `gatherUsageStats=false`. @@ -81,3 +86,8 @@ Tema do Streamlit (`.streamlit/config.toml`): `base="dark"`, `primaryColor="#D4A - **[A CONFIRMAR] (Marcos)** rótulos dos códigos de escolaridade, moradia e verificação (`CODIGOS` em `componentes.py`, tirados do dicionário do Bondora). - **[PENDENTE] (Yasmin)** mostrar "a.a. nominal" ao lado dos juros. - **[PENDENTE] (depende de outras frentes)** `juro_pares_mediana` como referência ao lado do juro justo ("Mediana dos pares: X%", oculto quando `null`; até 09/11); `contexto_mercado` do BC no parecer; decisões gravadas no banco e marcação "já decidido" na fila; dados reais da carteira (08/11 e 09/11). + +## Estado em 10/11/2026 + +- **[PRONTO]** emojis trocados por ícones brancos; CSS separado em `estilo.css`; abas principais como cartões e etapas como abas de texto pequenas; etapas travadas e execução só com as 3 confirmações. +- **[DECIDIDO]** sem tela de login: o sistema roda no PC de cada pessoa e o nome do analista é digitado (obrigatório para decidir). Login só faria sentido com o site publicado. diff --git a/src/app/componentes.py b/src/app/componentes.py index ed3091f..fe84c17 100644 --- a/src/app/componentes.py +++ b/src/app/componentes.py @@ -394,4 +394,26 @@ def html_tabela_equidade(linhas: list[dict]) -> str: ComparaçãoDiferençaIC 95%Leitura {"".join(corpo)} - """) \ No newline at end of file + """) + +# ---------------------------------------------------------------- ícones +# Traço fino (estilo "line icons"), cor pelo texto em volta (currentColor): brancos no painel. +# Substituem os emojis no HTML; nos botões do Streamlit usamos os ícones Material (icon=":material/...:"). + +_ICONES = { + "balanca": '', + "grafico": '', + "chip": '', + "documento": '', + "cadeado": '', +} + + +def icone(nome: str, tamanho: int = 18, classe: str = "ap-ico") -> str: + """SVG inline de um ícone de linha. Nome desconhecido → string vazia (nunca quebra a tela).""" + corpo = _ICONES.get(nome) + if not corpo: + return "" + return (f'{corpo}') diff --git a/src/app/estilo.css b/src/app/estilo.css new file mode 100644 index 0000000..f5e7694 --- /dev/null +++ b/src/app/estilo.css @@ -0,0 +1,259 @@ +/* Estilo do painel do Auditor de Preço Justo. + Carregado por src/app/main.py (carregar_estilo). Cores: dourado #c89a58/#e4bd7d, fundo #121315. */ +@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=DM+Sans:wght@300;400;500;600&display=swap'); + +:root { + --gold: #c89a58; + --gold-light: #e4bd7d; + --dark-bg: #121315; + --card-bg: #1c1d21; + --card-border: rgba(200, 154, 88, 0.2); + --text-light: #f0f2f4; + --text-muted: #9aa5b5; +} +html { scroll-behavior: smooth; } +#MainMenu, header, footer, [data-testid="stSidebar"] { display: none !important; } +.block-container { padding: 0 !important; max-width: 100% !important; } +body { background-color: var(--dark-bg); color: var(--text-light); font-family: 'DM Sans', sans-serif; } + +/* --- NAVBAR SUPERIOR FIXA --- */ +.top-navbar { + position: fixed; top: 0; left: 0; width: 100%; height: 70px; + background-color: rgba(18, 19, 21, 0.95); backdrop-filter: blur(10px); + border-bottom: 1px solid var(--card-border); + display: flex; align-items: center; justify-content: space-between; + padding: 0 4rem; z-index: 9999; +} +.nav-brand { + font-family: 'Playfair Display', serif; font-size: 1.4rem; font-weight: 700; + color: #ffffff !important; letter-spacing: 1px; text-decoration: none; + display: flex; align-items: center; gap: 10px; +} +.nav-links { display: flex; gap: 2.5rem; align-items: center; } +.nav-link { color: #ffffff !important; text-decoration: none; font-weight: 500; font-size: 0.95rem; transition: color 0.3s ease; } +.nav-link:hover { color: var(--gold); } +.nav-analyst { color: var(--text-muted); font-size: .82rem; } +.nav-analyst b { color: var(--gold-light); font-weight: 600; } + +/* --- HERO SECTION --- */ +.hero-container { + margin-top: 0px; + background-image: linear-gradient(to right, rgba(18, 19, 21, 0.95) 25%, rgba(18, 19, 21, 0.7) 100%), + url('https://images.unsplash.com/photo-1589829085413-56de8ae18c73?q=80&w=2000&auto=format&fit=crop'); + background-color: #16171a; + background-size: cover; background-position: center; + padding: 25rem 4rem 4rem 4rem; + border-bottom: 5px solid var(--card-border); +} +.hero-title { font-family: 'Playfair Display', serif; font-size: 3.8rem; font-weight: 700; color: var(--text-light); line-height: 1.15; margin-bottom: 1rem; } +.hero-title span { color: var(--gold); font-style: italic; } +.hero-subtitle { font-size: 1.15rem; color: var(--text-muted); max-width: 680px; margin-bottom: 2rem; line-height: 1.6; } + +/* --- FAIXA ESCURA COM 3 BLOCOS EXPLICATIVOS --- */ +.features-strip { background-color: #0b0c0e; border-bottom: 1px solid var(--card-border); padding: 2.5rem 4rem; } +.features-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2rem; max-width: 1300px; margin: 0 auto; } +.feature-item { display: flex; align-items: flex-start; gap: 1.2rem; background: rgba(28, 29, 33, 0.15); padding: 1.5rem; border-radius: 12px; } +.feature-icon { font-size: 1.8rem; color: var(--gold); } +.feature-title { font-family: 'Playfair Display', serif; font-size: 1.15rem; color: var(--text-light); margin-bottom: 0.4rem; } +.feature-desc { font-size: 0.88rem; color: var(--text-muted); line-height: 1.5; } + +/* --- SEÇÕES --- */ +.section-wrapper { padding: 4rem 4rem; max-width: 1400px; margin: 0 auto; } +.section-header { text-align: center; margin-bottom: 2rem; } +.section-header h2 { font-family: 'Playfair Display', serif; font-size: 2.6rem; color: var(--text-light); } +.section-header p { color: var(--text-muted); font-size: 1.05rem; } +.gold-divider { width: 60px; height: 3px; background-color: var(--gold); margin: 1rem auto; border-radius: 2px; } + +/* --- SOBRE (EQUIPE) --- */ +.team-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.8rem; margin-top: 2.5rem; padding: 0 4rem 4rem 4rem; max-width: 1400px; margin-left: auto; margin-right: auto; } +.team-card { background-color: var(--card-bg); border: 1px solid var(--card-border); border-radius: 12px; padding: 1.2rem; text-align: left; transition: transform 0.3s ease, border-color 0.3s ease; } +.team-card:hover { transform: translateY(-5px); border-color: var(--gold); } +.team-avatar { + width: 100%; height: 150px; border-radius: 8px; margin-bottom: 1rem; + display: flex; align-items: center; justify-content: center; + background: radial-gradient(circle at 30% 20%, rgba(228,189,125,.22), rgba(18,19,21,1) 70%); + border: 1px solid rgba(255,255,255,0.05); + font-family: 'Playfair Display', serif; font-size: 3rem; color: var(--gold-light); letter-spacing: 2px; +} +.team-name { font-family: 'Playfair Display', serif; font-size: 1.25rem; color: var(--text-light); margin-bottom: 0.2rem; } +.team-role { font-size: 0.82rem; color: var(--gold); text-transform: uppercase; letter-spacing: 1px; font-weight: 600; margin-bottom: 0.6rem; } +.team-bio { font-size: 0.85rem; color: var(--text-muted); line-height: 1.4; } + +/* --- COMPONENTES STREAMLIT --- */ +[data-testid="stAppViewContainer"], [data-testid="stMain"] { background: var(--dark-bg); color: var(--text-light); } +[data-testid="stMain"] .block-container { padding-top: 1.5rem !important; padding-bottom: 4rem !important; } + +[data-testid="stButton"] button, [data-testid="stDownloadButton"] button { + min-height: 52px; width: 100%; white-space: pre-wrap !important; text-align: center !important; + line-height: 1.55 !important; padding: 1.1rem 1rem !important; border-radius: 14px !important; + border: 1px solid rgba(200, 154, 88, 0.34) !important; + background: linear-gradient(145deg, #202126 0%, #17181c 100%) !important; + color: var(--text-light) !important; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16); + transition: transform .2s ease, border-color .2s ease, box-shadow .2s ease; +} +[data-testid="stButton"] button:hover, [data-testid="stDownloadButton"] button:hover { + transform: translateY(-3px); border-color: var(--gold-light) !important; box-shadow: 0 12px 28px rgba(200, 154, 88, 0.12); +} +[data-testid="stButton"] button[data-testid="stBaseButton-primary"], +[data-testid="stButton"] button[kind="primary"] { + background: linear-gradient(135deg, #d7b9b8 0%, #c8a2a0 100%) !important; + color: #171315 !important; border: 1px solid #e5c5c3 !important; font-weight: 700 !important; +} + +.audit-workspace-label { color: var(--gold-light); font-size: .68rem; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; margin: .15rem 0 .4rem 0; } +/* --- ETAPAS DO FORMULÁRIO: abas de texto pequenas, à esquerda, sem caixa --- + Só muda a cor no hover; a etapa aberta fica dourada com um traço fino embaixo. */ +.st-key-audit_steps { border-bottom: 1px solid rgba(200,154,88,.18); margin-bottom: 1.1rem; } +.st-key-audit_steps [data-testid="stHorizontalBlock"] { + justify-content: flex-start !important; gap: 1.8rem !important; flex-wrap: wrap; +} +.st-key-audit_steps [data-testid="stColumn"], .st-key-audit_steps [data-testid="column"] { + flex: 0 0 auto !important; width: auto !important; min-width: 0 !important; +} +.st-key-audit_select_personal button, .st-key-audit_select_loan button, .st-key-audit_select_history button { + min-height: 0 !important; height: auto !important; width: auto !important; + padding: .55rem .1rem .6rem .1rem !important; margin-bottom: -1px; + background: transparent !important; border: none !important; border-bottom: 2px solid transparent !important; + border-radius: 0 !important; box-shadow: none !important; transform: none !important; + color: var(--text-muted) !important; font-weight: 500 !important; font-size: .84rem !important; letter-spacing: .02em; + transition: color .15s ease, border-color .15s ease; +} +.st-key-audit_select_personal button p, .st-key-audit_select_loan button p, .st-key-audit_select_history button p { + color: inherit !important; font-weight: inherit !important; font-size: inherit !important; +} +.st-key-audit_select_personal button:hover, .st-key-audit_select_loan button:hover, .st-key-audit_select_history button:hover { + color: var(--gold-light) !important; background: transparent !important; box-shadow: none !important; + transform: none !important; +} +.st-key-audit_select_personal button[kind="primary"], .st-key-audit_select_loan button[kind="primary"], .st-key-audit_select_history button[kind="primary"], +.st-key-audit_select_personal button[data-testid="stBaseButton-primary"], .st-key-audit_select_loan button[data-testid="stBaseButton-primary"], +.st-key-audit_select_history button[data-testid="stBaseButton-primary"] { + color: var(--gold-light) !important; background: transparent !important; + border: none !important; border-bottom: 2px solid var(--gold) !important; font-weight: 600 !important; +} +/* ícones das etapas: brancos e discretos (a cor do texto muda, o ícone não) */ +.st-key-audit_steps [data-testid="stIconMaterial"] { + color: #f0f2f4 !important; opacity: .75; font-size: 1.05rem !important; +} +/* etapa travada: aparece, mas não abre até a anterior ser revisada */ +.st-key-audit_select_personal button:disabled, .st-key-audit_select_loan button:disabled, .st-key-audit_select_history button:disabled { + opacity: .38 !important; cursor: not-allowed !important; background: transparent !important; +} +/* botão de executar travado até as 3 caixas */ +.st-key-audit_execute button:disabled { opacity: .45 !important; cursor: not-allowed !important; transform: none !important; } +.audit-lock-note { color: var(--text-muted); font-size: .78rem; margin: .35rem 0 .2rem 0; display: flex; align-items: center; gap: .4rem; } +/* ícones de linha (componentes.icone): brancos, alinhados ao texto */ +.ap-ico { display: inline-block; vertical-align: -0.2em; color: #f0f2f4; flex: none; } +.feature-icon .ap-ico { color: #f0f2f4; opacity: .9; } +.nav-brand .ap-ico { margin-right: .35rem; } + +.audit-review-progress { height: 7px; width: 100%; background: rgba(238, 233, 229, .12); border-radius: 10px; overflow: hidden; margin: .75rem 0 1.35rem 0; } +.audit-review-progress-fill { height: 100%; background: linear-gradient(90deg, #c89a58, #e4bd7d); border-radius: 10px; transition: width .25s ease; } +.audit-review-status { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin: .95rem 0 .45rem 0; color: #e8e5df; font-size: .76rem; font-weight: 600; } +[data-testid="stVerticalBlockBorderWrapper"] { + background: linear-gradient(145deg, rgba(31, 32, 37, .96), rgba(23, 24, 28, .96)); + border: 1px solid rgba(200, 154, 88, .24) !important; border-radius: 16px !important; padding: 1.1rem 1.2rem !important; +} +[data-testid="stTextInput"] input, [data-testid="stNumberInput"] input, [data-testid="stDateInput"] input, +[data-testid="stTextArea"] textarea, [data-testid="stSelectbox"] [data-baseweb="select"] > div { + background: #141519 !important; color: var(--text-light) !important; + border-color: rgba(200, 154, 88, .28) !important; border-radius: 8px !important; +} +.audit-eyebrow { color: var(--gold-light); font-size: .72rem; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; margin-bottom: .45rem; } +.audit-panel-title { color: var(--text-light); font-family: 'Playfair Display', serif; font-size: 1.65rem; margin: 0 0 .35rem 0; } +.audit-progress-title { color: var(--text-light); font-family: 'Playfair Display', serif; font-size: 1.25rem; margin: 0 0 .3rem 0; } +.skill-track { width: 100%; height: 7px; background: #eee9e5; border-radius: 1px; overflow: hidden; } +.skill-fill { height: 100%; background: #171719; border-radius: 1px; transition: width .25s ease; } +.audit-note { + background: rgba(200, 154, 88, .08); border-left: 3px solid var(--gold); border-radius: 0 8px 8px 0; + padding: .8rem .95rem; color: #c6c2bb; font-size: .8rem; line-height: 1.5; margin-top: .8rem; +} + +/* --- ABAS PRINCIPAIS: estilo de cartões grandes (gradiente dourado na aba ativa) --- */ +[data-testid="stTabs"] [data-baseweb="tab-list"] { + display: grid !important; grid-template-columns: repeat(3, 1fr); gap: .6rem; border-bottom: none !important; + margin: .6rem 0 1.4rem 0; +} +[data-testid="stTabs"] [data-baseweb="tab"] { + height: 104px !important; min-height: 104px; width: 100%; justify-content: center; align-items: center; + padding: .55rem .35rem !important; border-radius: 10px !important; + border: 1px solid rgba(200, 154, 88, 0.34) !important; + background: linear-gradient(145deg, #202126 0%, #17181c 100%) !important; + color: var(--text-light) !important; transition: transform .2s ease, border-color .2s ease; +} +[data-testid="stTabs"] [data-baseweb="tab"] p { + font-size: .74rem !important; font-weight: 700 !important; letter-spacing: .1em; color: inherit !important; +} +[data-testid="stTabs"] [data-baseweb="tab"]:hover { transform: translateY(-3px); border-color: var(--gold-light) !important; } +[data-testid="stTabs"] [aria-selected="true"] { + background: linear-gradient(135deg, #c89a58 0%, #e4bd7d 100%) !important; + color: #171315 !important; border: 1px solid #e4bd7d !important; +} +[data-testid="stTabs"] [data-baseweb="tab-highlight"], [data-testid="stTabs"] [data-baseweb="tab-border"] { display: none !important; } + +/* --- BLOCOS DO RESULTADO (ap-*) --- */ +.ap-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; margin: 1.6rem 0 .6rem 0; } +.ap-title { font-family: 'Playfair Display', serif; font-size: 2.1rem; color: var(--text-light); line-height: 1.1; } +.ap-meta { color: var(--text-muted); font-size: .9rem; margin-top: .35rem; } +.ap-chips { display: flex; flex-wrap: wrap; gap: .45rem; margin: .6rem 0 1.1rem 0; } +.ap-chip { font-size: .74rem; color: #d9d4cb; background: rgba(255,255,255,.04); border: 1px solid rgba(200,154,88,.22); border-radius: 999px; padding: .28rem .7rem; } +.ap-chip-gold { color: #171315; background: var(--gold-light); border-color: var(--gold-light); font-weight: 700; } +.ap-badge { display: inline-block; font-size: .72rem; font-weight: 700; border: 1px solid; border-radius: 999px; padding: .2rem .65rem; white-space: nowrap; } + +.ap-kpi { background: linear-gradient(145deg, rgba(31,32,37,.96), rgba(23,24,28,.96)); border: 1px solid rgba(200,154,88,.24); border-radius: 16px; padding: 1.1rem 1.2rem; min-height: 152px; } +.ap-kpi-label { color: var(--text-muted); font-size: .78rem; font-weight: 600; letter-spacing: .04em; } +.ap-kpi-value { font-family: 'Playfair Display', serif; font-size: 2.25rem; color: var(--gold-light); margin: .35rem 0 .2rem 0; line-height: 1.1; } +.ap-kpi-unit { font-family: 'DM Sans', sans-serif; font-size: .85rem; color: var(--text-muted); margin-left: .3rem; } +.ap-kpi-sub { color: #b9b4ab; font-size: .78rem; line-height: 1.4; } +.ap-kpi-foot { margin-top: .55rem; } +.ap-faixa { display: inline-block; background: #0b0c0e; color: var(--gold-light); border: 1px solid var(--gold); border-radius: 6px; padding: .15rem .55rem; font-size: .75rem; font-weight: 700; } +.ap-faixa-off { color: var(--text-muted); border-color: rgba(154,165,181,.4); } + +.ap-card { background: linear-gradient(145deg, rgba(31,32,37,.96), rgba(23,24,28,.96)); border: 1px solid rgba(200,154,88,.24); border-radius: 16px; padding: 1.25rem 1.35rem; margin-top: 1rem; } +.ap-card-title { font-family: 'Playfair Display', serif; font-size: 1.3rem; color: var(--text-light); margin-bottom: .2rem; } +.ap-card-sub { color: var(--text-muted); font-size: .8rem; margin-bottom: 1rem; } +.ap-alert { border-left: 3px solid #9aa5b5; background: rgba(154,165,181,.08); border-radius: 0 10px 10px 0; padding: .9rem 1rem; color: #d7dbe2; font-size: .86rem; line-height: 1.5; margin-top: 1rem; } + +.ap-ruler { padding: 2.1rem .4rem .2rem .4rem; } +.ap-ruler-track { position: relative; height: 14px; background: #0f1013; border-radius: 8px; border: 1px solid rgba(255,255,255,.04); } +.ap-ruler-band { position: absolute; top: 0; bottom: 0; background: rgba(228,189,125,.28); border-radius: 8px; } +.ap-ruler-mark { position: absolute; top: -9px; width: 3px; height: 32px; border-radius: 2px; transform: translateX(-50%); } +.ap-ruler-label { position: absolute; transform: translateX(-50%); font-size: .76rem; font-weight: 700; white-space: nowrap; } +.ap-up { top: -32px; } +.ap-down { top: 28px; } +.ap-ruler-ticks { position: relative; height: 22px; margin-top: 2.2rem; } +.ap-tick { position: absolute; transform: translateX(-50%); font-size: .7rem; color: var(--text-muted); } + +.ap-legend { display: flex; flex-wrap: wrap; gap: 1.1rem; color: var(--text-muted); font-size: .74rem; margin-top: .9rem; align-items: center; } +.ap-legend i { display: inline-block; width: 11px; height: 11px; border-radius: 3px; margin-right: .4rem; vertical-align: -1px; } + +.ap-factor { display: grid; grid-template-columns: 1.25fr 1fr 2.6rem; gap: .8rem; align-items: center; margin: .55rem 0; } +.ap-factor-name { font-size: .85rem; color: #e8e5df; line-height: 1.3; } +.ap-factor-bar { height: 9px; background: #0f1013; border-radius: 6px; overflow: hidden; } +.ap-factor-bar div { height: 100%; border-radius: 6px; } +.ap-factor-w { font-size: .72rem; color: var(--text-muted); text-align: right; } + +.ap-parecer-head { display: flex; justify-content: space-between; align-items: center; gap: .8rem; margin-bottom: .6rem; } +.ap-sec { color: var(--gold-light); font-size: .68rem; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; margin: 1rem 0 .35rem 0; } +.ap-text { color: #e8e5df; font-size: .9rem; line-height: 1.6; margin: 0; } +.ap-list { margin: 0; padding-left: 1.1rem; color: #e8e5df; font-size: .88rem; line-height: 1.6; } +.ap-foot { color: var(--text-muted); font-size: .7rem; margin-top: .9rem; } +.ap-muted { color: var(--text-muted); } +.ap-small { font-size: .72rem; } +.ap-empty { color: var(--text-muted); font-size: .85rem; padding: .6rem 0; } +.ap-mercado { border-left: 3px solid #7fa7d9; background: rgba(127,167,217,.08); border-radius: 0 8px 8px 0; + padding: .7rem .9rem; color: #d7dbe2; font-size: .84rem; line-height: 1.55; } + +.ap-stack-row { display: grid; grid-template-columns: 4.2rem 1fr 3.2rem; gap: .8rem; align-items: center; margin: .5rem 0; } +.ap-stack-label { font-size: .84rem; font-weight: 700; color: #e8e5df; } +.ap-stack { display: flex; height: 16px; border-radius: 6px; overflow: hidden; background: #0f1013; } +.ap-stack div { height: 100%; } +.ap-stack-n { font-size: .72rem; color: var(--text-muted); text-align: right; } + +.ap-table { width: 100%; border-collapse: collapse; font-size: .84rem; color: #e8e5df; } +.ap-table th { text-align: left; color: var(--text-muted); font-size: .68rem; letter-spacing: .12em; text-transform: uppercase; font-weight: 700; padding: .55rem .5rem; border-bottom: 1px solid rgba(200,154,88,.3); } +.ap-table td { padding: .65rem .5rem; border-bottom: 1px solid rgba(255,255,255,.06); vertical-align: middle; } +.ap-table tr:hover td { background: rgba(200,154,88,.04); } + +.ap-status { display: inline-flex; align-items: center; gap: .45rem; font-size: .76rem; color: var(--text-muted); } +.ap-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } diff --git a/src/app/main.py b/src/app/main.py index d8128f6..c7ec96a 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -1,6 +1,7 @@ """Painel do analista — Auditor de Preço Justo (Streamlit). Telas: Revisão de contrato · Auditoria da carteira · Registro de decisões. +O estilo fica em src/app/estilo.css; os blocos de HTML do resultado, em componentes.py. O painel só EXIBE: todos os números vêm da API (prever() do Samuel) ou dos arquivos do lote da carteira; o parecer vem da GenAI (ou do template de reserva). """ @@ -23,7 +24,7 @@ from src.app.componentes import (PAISES, RECOMENDACOES, SITUACOES, badge, badge_situacao, # noqa: E402 compactar, data_br, esc, eur, html_barra_juros, html_barras_faixa, html_motivos, html_parecer, - html_tabela_equidade, kpis_revisao, pct) + html_tabela_equidade, icone, kpis_revisao, pct) FUSO = timezone(timedelta(hours=-3)) CASOS_REAIS = RAIZ / "docs" / "casos_de_teste.json" # saídas reais do prever() (Samuel) @@ -34,7 +35,7 @@ # ========================================== st.set_page_config( page_title="Auditor de Preço Justo | Auditoria de Crédito", - page_icon="⚖️", + page_icon=":material/balance:", layout="wide", initial_sidebar_state="collapsed", ) @@ -44,217 +45,17 @@ TIMEOUT_CONSULTA_S = 5 # leituras rápidas (decisões) # ========================================== -# 2. ESTILIZAÇÃO CSS CUSTOMIZADA (FINTECH / LEGAL-TECH) +# 2. ESTILO (src/app/estilo.css) # ========================================== -st.markdown(""" - -""", unsafe_allow_html=True) +def carregar_estilo() -> None: + """O CSS mora em estilo.css; aqui só é injetado na página.""" + st.markdown(f"", unsafe_allow_html=True) + + +carregar_estilo() def md(bloco: str) -> None: @@ -404,7 +205,7 @@ def csv_da_fila(anos, pais, faixa) -> bytes: analista_nav = esc(st.session_state.analista) or "não identificado" md(f"""
- ⚖️ AUDITOR DE PREÇO JUSTO + {icone('balanca', 20)} AUDITOR DE PREÇO JUSTO """) -md(""" +md(f"""
-
📊
+
{icone('grafico', 26)}
Juro justo por fluxo de caixa
Custo do dinheiro (Euribor da época), perda esperada ao longo do contrato e custos calibrados na carteira, com margem de incerteza.
-
🤖
+
{icone('chip', 26)}
Modelo de risco explicável
XGBoost calibrado estima a probabilidade de default; os principais fatores de cada contrato aparecem com nomes legíveis.
-
📜
+
{icone('documento', 26)}
Parecer com normas e checagem
IA generativa com busca em CDC, LGPD e normas do CMN. Os números do texto são conferidos por código; se algo falhar, entra o template.
@@ -494,8 +295,42 @@ def csv_da_fila(anos, pais, faixa) -> bytes: # ------------------------------------------ # 6.1 Formulário em etapas # ------------------------------------------ +ETAPAS = ("personal", "loan", "history") +CAMPOS_DA_ETAPA = { + "personal": ("age", "gender", "country", "income_total"), + "loan": ("loan_id", "amount", "interest", "loan_duration", "data_emprestimo"), + "history": ("liabilities_total",), +} +ETAPA_DO_CAMPO = {campo: etapa for etapa, campos in CAMPOS_DA_ETAPA.items() for campo in campos} + + +def _desmarcar_revisao(step_name: str) -> None: + st.session_state[f"audit_reviewed_{step_name}"] = False + if f"_audit_review_widget_{step_name}" in st.session_state: + st.session_state[f"_audit_review_widget_{step_name}"] = False + + def _persist_audit_input(field_name: str) -> None: st.session_state[f"audit_{field_name}"] = st.session_state[f"_audit_widget_{field_name}"] + # dado alterado depois de revisado → a etapa precisa ser revisada de novo + _desmarcar_revisao(ETAPA_DO_CAMPO.get(field_name, "history")) + + +def etapa_revisada(step_name: str) -> bool: + return bool(st.session_state.get(f"audit_reviewed_{step_name}")) + + +def etapa_liberada(step_name: str) -> bool: + """Uma etapa só abre quando todas as anteriores estão marcadas como revisadas.""" + return all(etapa_revisada(e) for e in ETAPAS[:ETAPAS.index(step_name)]) + + +def todas_revisadas() -> bool: + return all(etapa_revisada(e) for e in ETAPAS) + + +def primeira_pendente() -> str: + return next((e for e in ETAPAS if not etapa_revisada(e)), ETAPAS[-1]) def _prepare_audit_widget(field_name: str) -> str: @@ -542,8 +377,18 @@ def audit_review_checkbox(step_name: str) -> None: on_change=_persist_review, args=(step_name,)) -STEP_CARDS = [("personal", "\n\nDADOS PESSOAIS\n\n"), ("loan", "\n\nEMPRÉSTIMO\n\n"), - ("history", "\n\nDÍVIDAS & HISTÓRICO\n\n")] +def botao_proxima(step_name: str) -> None: + """Atalho para a etapa seguinte; só funciona com a etapa atual marcada como revisada.""" + seguinte = ETAPAS[ETAPAS.index(step_name) + 1] + rotulo = dict(STEP_CARDS)[seguinte] + if st.button(f"Avançar para {rotulo}", key=f"audit_next_{step_name}", icon=":material/arrow_forward:", + disabled=not etapa_revisada(step_name)): + st.session_state.audit_active_step = seguinte + st.rerun() + + +STEP_CARDS = [("personal", "Dados pessoais"), ("loan", "Empréstimo"), ("history", "Dívidas & histórico")] +ICONES_ETAPA = {"personal": ":material/person:", "loan": ":material/payments:", "history": ":material/history:"} STEP_DETAILS = { "personal": {"number": "01", "title": "Dados pessoais", "copy": "Perfil do tomador no momento do pedido. Gênero e idade ficam registrados só para a auditoria de equidade e nunca entram no modelo."}, @@ -556,13 +401,25 @@ def audit_review_checkbox(step_name: str) -> None: def formulario() -> bool: """Desenha o formulário em 3 etapas. Devolve True quando o botão de auditoria é clicado.""" - _, nav_center, _ = st.columns([0.35, 2.3, 0.35], gap="small") - with nav_center: + # Etapa aberta que deixou de estar liberada (ex.: desmarcou a revisão anterior) → volta + # para a primeira etapa pendente. Voltar é sempre permitido; avançar, só revisando. + if not etapa_liberada(st.session_state.audit_active_step): + st.session_state.audit_active_step = primeira_pendente() + + with st.container(key="audit_steps"): for column, (step_key, step_label) in zip(st.columns(3, gap="small"), STEP_CARDS): with column: ativo = st.session_state.audit_active_step == step_key - if st.button(step_label, key=f"audit_select_{step_key}", - type="primary" if ativo else "secondary", use_container_width=True): + liberada = etapa_liberada(step_key) + if not liberada: + icone_etapa = ":material/lock:" + elif etapa_revisada(step_key): + icone_etapa = ":material/check_circle:" + else: + icone_etapa = ICONES_ETAPA[step_key] + if st.button(step_label, key=f"audit_select_{step_key}", disabled=not liberada, icon=icone_etapa, + type="primary" if ativo else "secondary", + help=None if liberada else "Marque “Confirmo que revisei” na etapa anterior para liberar."): st.session_state.audit_active_step = step_key st.rerun() @@ -592,6 +449,7 @@ def formulario() -> bool: st.markdown('
Valores em euro (€). Renda e passivos da Estônia até 2010 ' 'foram convertidos de coroas estonianas na carga.
', unsafe_allow_html=True) audit_review_checkbox("personal") + botao_proxima("personal") elif active_step == "loan": audit_text("ID do contrato", "loan_id", max_chars=40) @@ -604,6 +462,7 @@ def formulario() -> bool: 'Para meses após o fim da série, o modelo usa o último valor disponível. ' 'Juros acima de 100% a.a. existem na base e são legítimos.
', unsafe_allow_html=True) audit_review_checkbox("loan") + botao_proxima("loan") else: audit_number("Total de dívidas em outras instituições (€)", "liabilities_total", @@ -614,8 +473,14 @@ def formulario() -> bool: audit_review_checkbox("history") st.markdown("
", unsafe_allow_html=True) - clicou = st.button("⚖️ EXECUTAR AUDITORIA DE PREÇO", key="audit_execute", + liberado = todas_revisadas() + if not liberado: + faltam = [d["title"] for e, d in STEP_DETAILS.items() if not etapa_revisada(e)] + md(f'
{icone("cadeado", 14)} A auditoria libera depois das 3 confirmações. ' + f'Falta revisar: {esc(", ".join(faltam))}.
') + clicou = st.button("EXECUTAR AUDITORIA DE PREÇO", key="audit_execute", icon=":material/balance:", disabled=not liberado, type="primary", use_container_width=True) + clicou = clicou and liberado # trava também no código, não só no botão with progress_column: with st.container(border=True): diff --git a/tests/app/test_componentes.py b/tests/app/test_componentes.py index 9030f13..9e5fe4d 100644 --- a/tests/app/test_componentes.py +++ b/tests/app/test_componentes.py @@ -125,3 +125,12 @@ def test_duas_casas_quando_os_juros_ficariam_iguais(): assert "30,56%" in cartoes[1] and "30,58%" in cartoes[2] assert "Cobrado 30,56%" in html_barra_juros(r) assert "24,9%" in kpis_revisao(POR_SITUACAO["acima_do_justo"])[1] # caso normal: 1 casa + + +def test_icones_de_linha_sem_emoji(): + from src.app.componentes import icone + svg = icone("cadeado", 14) + assert svg.startswith("