mirror of
https://github.com/hoshikawa2/select_ai.git
synced 2026-03-06 02:10:38 +00:00
First Commit
This commit is contained in:
12
.idea/.gitignore
generated
vendored
Normal file
12
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Environment-dependent path to Maven home directory
|
||||||
|
/mavenHomeManager.xml
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# Zeppelin ignored files
|
||||||
|
/ZeppelinRemoteNotebooks/
|
||||||
7
.idea/codeStyles/Project.xml
generated
Normal file
7
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<code_scheme name="Project" version="173">
|
||||||
|
<ScalaCodeStyleSettings>
|
||||||
|
<option name="MULTILINE_STRING_CLOSING_QUOTES_ON_NEW_LINE" value="true" />
|
||||||
|
</ScalaCodeStyleSettings>
|
||||||
|
</code_scheme>
|
||||||
|
</component>
|
||||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
6
.idea/misc.xml
generated
Normal file
6
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="graalvm-jdk-17" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/select_ai.iml" filepath="$PROJECT_DIR$/.idea/select_ai.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
9
.idea/select_ai.iml
generated
Normal file
9
.idea/select_ai.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
24
.oca/custom_code_review_guidelines.txt
Normal file
24
.oca/custom_code_review_guidelines.txt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Sample guideline, please follow similar structure for guideline with code samples
|
||||||
|
# 1. Suggest using streams instead of simple loops for better readability.
|
||||||
|
# <example>
|
||||||
|
# *Comment:
|
||||||
|
# Category: Minor
|
||||||
|
# Issue: Use streams instead of a loop for better readability.
|
||||||
|
# Code Block:
|
||||||
|
#
|
||||||
|
# ```java
|
||||||
|
# // Calculate squares of numbers
|
||||||
|
# List<Integer> squares = new ArrayList<>();
|
||||||
|
# for (int number : numbers) {
|
||||||
|
# squares.add(number * number);
|
||||||
|
# }
|
||||||
|
# ```
|
||||||
|
# Recommendation:
|
||||||
|
#
|
||||||
|
# ```java
|
||||||
|
# // Calculate squares of numbers
|
||||||
|
# List<Integer> squares = Arrays.stream(numbers)
|
||||||
|
# .map(n -> n * n) // Map each number to its square
|
||||||
|
# .toList();
|
||||||
|
# ```
|
||||||
|
# </example>
|
||||||
395
README.md
395
README.md
@@ -1 +1,394 @@
|
|||||||
# select_ai
|
# Crie um Chat com Dashboard utilizando **Oracle Autonomous Database SELECT AI**
|
||||||
|
|
||||||
|
## 1. Introdução
|
||||||
|
|
||||||
|
Este tutorial cria uma **interface web** simples (chat + cards com **gráficos** e **tabelas**) transformando **perguntas em linguagem natural** (pt-BR) em **SQL** automaticamente, usando o **SELECT AI** do Autonomous Database. É ideal para cenários como **Pronto-Socorro (ER)**, vendas, logística etc., quando usuários de negócio querem **insights imediatos** sem escrever SQL.
|
||||||
|
|
||||||
|
### Como funciona:
|
||||||
|
|
||||||
|
- O **Flask** serve uma página com um campo de pergunta e um histórico de respostas (cada resposta pode ter **SQL gerado**, **tabela** e **gráfico Chart.js**).
|
||||||
|
- Ao enviar a pergunta, o backend chama `SELECT AI 'sua pergunta' FROM <tabela/view>` — quem gera e executa o SQL é o **SELECT AI** dentro do banco.
|
||||||
|
- O app formata os resultados, tenta inferir o **tipo de gráfico** e permite **exportar tudo** para **PDF** (html2canvas + jsPDF, no navegador) e **Excel** (duas opções: no navegador com XlsxPopulate ou **no servidor** com `openpyxl`).
|
||||||
|
|
||||||
|
### Tecnologias principais:
|
||||||
|
- **Oracle Autonomous Database 23ai** com **SELECT AI** e **DBMS_CLOUD_AI** (perfis de LLM)
|
||||||
|
- **Python** + **Flask** + **python-oracledb (Thin)** com **mTLS** (wallet)
|
||||||
|
- **Front-end**: Chart.js, html2canvas + jsPDF e XlsxPopulate (via CDN)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Pré-requisitos
|
||||||
|
|
||||||
|
**Oracle Cloud**
|
||||||
|
- Um **Autonomous Database (Serverless)** em **23ai** (ou com SELECT AI disponível).
|
||||||
|
- **Saída de rede do ADB** para acessar o serviço OCI Generative AI.
|
||||||
|
- Permissões para usar **DBMS_CLOUD** / **DBMS_CLOUD_AI** e criar **perfis de IA**.
|
||||||
|
- **Modelo/Região** do OCI Generative AI habilitados (ex.: `cohere.command-r-08-2024` em `us-chicago-1`).
|
||||||
|
|
||||||
|
**Uma VM Linux para hospedagem da página Web (app Flask)**
|
||||||
|
- **Python 3.10+** (recomendado).
|
||||||
|
- Pacotes: `flask`, `oracledb`, `openpyxl`, `pillow`.
|
||||||
|
- **Wallet** do ADB (ZIP baixado do console).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Entendendo o código (trecho a trecho)
|
||||||
|
|
||||||
|
O código-fonte pode ser baixado aqui: [app_select_ai.py](./files/app_select_ai.py)
|
||||||
|
|
||||||
|
### Conexão e sessão com perfil de IA
|
||||||
|
- Lê **config** (wallet, alias TNS, usuário/senha).
|
||||||
|
- Define **`TNS_ADMIN`**.
|
||||||
|
- `session_callback` ativa o **perfil de IA** (garante SELECT AI pronto em cada sessão).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### run_select_ai
|
||||||
|
- Monta a sentença **`SELECT AI 'pergunta' FROM <tabela>`**.
|
||||||
|
- Executa e retorna **SQL gerado**, **headers/rows** e usuário/perfil ativos.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### build_chart e format_table
|
||||||
|
- `build_chart`: heurísticas para inferir gráfico (pizza, barras, linha).
|
||||||
|
- `format_table`: limita registros e devolve `{headers, rows}`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
### Front-end (PAGE)
|
||||||
|
- **Chart.js** desenha gráficos; toolbar alterna tipos.
|
||||||
|
- **Exportar PDF**: usa html2canvas + jsPDF.
|
||||||
|
- **Exportar Excel**: cliente (XlsxPopulate) ou servidor (openpyxl).
|
||||||
|
|
||||||
|
### Histórico (session)
|
||||||
|
- Mantém últimos 10 cards em `timeline`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. SELECT AI no Autonomous Database
|
||||||
|
|
||||||
|
- Permite **NL→SQL** direto no banco.
|
||||||
|
- Configuração feita via **Perfis de IA** (`DBMS_CLOUD_AI.CREATE_PROFILE`).
|
||||||
|
- Perfis incluem provider, modelo, region, credential e lista de objetos (tables/views).
|
||||||
|
- Comandos auxiliares: `showsql`, `explain`, `narrate` etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Instalação e implantação
|
||||||
|
|
||||||
|
### 5.1. Criar usuário e permissionamento como ADMIN
|
||||||
|
|
||||||
|
Execute estes comandos como ADMIN no Autonomous Database:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE USER MEU_USUARIO IDENTIFIED BY "SenhaForte123";
|
||||||
|
ALTER USER MEU_USUARIO QUOTA UNLIMITED ON DATA;
|
||||||
|
GRANT CREATE SESSION TO MEU_USUARIO;
|
||||||
|
GRANT CREATE TABLE, CREATE VIEW, CREATE SEQUENCE TO MEU_USUARIO;
|
||||||
|
GRANT SELECT ON ADMIN.DATASET_ED_ADMISSION TO MEU_USUARIO;
|
||||||
|
GRANT EXECUTE ON DBMS_CLOUD TO MEU_USUARIO;
|
||||||
|
GRANT EXECUTE ON DBMS_CLOUD_AI TO MEU_USUARIO;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Escolher o método de autenticação do SELECT AI
|
||||||
|
|
||||||
|
Há duas opções. Escolha uma opção (**A ou B**) conforme necessidade:
|
||||||
|
|
||||||
|
**Opção A** — Resource Principal (RP) (recomendada no ADB)
|
||||||
|
|
||||||
|
Sem armazenar chave no banco; usa a identidade do serviço ADB.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
BEGIN
|
||||||
|
DBMS_CLOUD_ADMIN.ENABLE_PRINCIPAL_AUTH(provider => 'OCI'); -- habilita provedor OCI no ADB
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
DBMS_CLOUD_ADMIN.ENABLE_RESOURCE_PRINCIPAL(); -- ativa RP no banco
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
DBMS_CLOUD_ADMIN.ENABLE_PRINCIPAL_AUTH( -- permite o schema usar RP
|
||||||
|
provider => 'OCI',
|
||||||
|
username => 'MEU_USUARIO'
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Opção B** — Credencial com API Key
|
||||||
|
|
||||||
|
Armazena uma credencial (OCID do usuário IAM + chave privada).
|
||||||
|
|
||||||
|
Substitua username e password pelos seus valores (OCID e chave/PEM).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
BEGIN
|
||||||
|
DBMS_CLOUD.CREATE_CREDENTIAL(
|
||||||
|
credential_name => 'OCI_GENAI_CRED',
|
||||||
|
username => 'ocid1.user.oc1..aaaa...vx', -- OCID do usuário IAM
|
||||||
|
password => 'SUA_CHAVE_PRIVADA_OU_CONTEUDO' -- chave/PEM
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Criar o(s) Perfil(is) de IA (SELECT AI)
|
||||||
|
|
||||||
|
O perfil define provedor, região, credencial/RP, quais objetos o LLM pode usar e (opcionalmente) o modelo.
|
||||||
|
|
||||||
|
Exemplo 1 — Perfil com Resource Principal
|
||||||
|
|
||||||
|
```sql
|
||||||
|
BEGIN
|
||||||
|
DBMS_CLOUD_AI.CREATE_PROFILE(
|
||||||
|
'OCI_GENAI',
|
||||||
|
'{
|
||||||
|
"provider": "OCI",
|
||||||
|
"credential_name": "OCI$RESOURCE_PRINCIPAL",
|
||||||
|
"object_list": [ { "owner": "ADMIN" } ],
|
||||||
|
"model": "cohere.command-r-08-2024",
|
||||||
|
"oci_runtimetype": "COHERE",
|
||||||
|
"temperature": "0.4"
|
||||||
|
}'
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemplo 2 — Perfil principal da aplicação (com Credencial)
|
||||||
|
|
||||||
|
Inclui explicitamente os objetos que o SELECT AI pode usar:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
|
||||||
|
DBMS_CLOUD_AI.CREATE_PROFILE(
|
||||||
|
profile_name => 'OCI_GENERATIVE_AI_PROFILE',
|
||||||
|
attributes =>
|
||||||
|
'{
|
||||||
|
"provider":"OCI",
|
||||||
|
"region":"us-chicago-1",
|
||||||
|
"credential_name":"OCI$RESOURCE_PRINCIPAL",
|
||||||
|
"object_list":[
|
||||||
|
{"owner":"ADMIN","name":"DATASET_ED_ADMISSION"},
|
||||||
|
{"owner":"MEU_USUARIO","name":"NLU_ED_ADMISSION"}
|
||||||
|
],
|
||||||
|
"model":"cohere.command-r-08-2024"
|
||||||
|
}'
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
```
|
||||||
|
|
||||||
|
>**Nota:** 💡 Dica: se preferir, você pode omitir "model" e deixar o ADB usar o modelo padrão da região/provedor.
|
||||||
|
|
||||||
|
>**Nota:** 🔒 Segurança: liste somente os objetos necessários em object_list.
|
||||||
|
|
||||||
|
### 5.4 Ativar o perfil e torná-lo ativo na sessão
|
||||||
|
|
||||||
|
```sql
|
||||||
|
BEGIN
|
||||||
|
DBMS_CLOUD_AI.ENABLE_PROFILE('OCI_GENERATIVE_AI_PROFILE');
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
EXEC DBMS_CLOUD_AI.SET_PROFILE('OCI_GENERATIVE_AI_PROFILE');
|
||||||
|
SELECT DBMS_CLOUD_AI.GET_PROFILE() AS active_after FROM dual;
|
||||||
|
```
|
||||||
|
|
||||||
|
>**IMPORTANTE:** É necessário executar este comando abaixo antes de cada consulta SELECT AI:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CALL DBMS_CLOUD_AI.SET_PROFILE('OCI_GENERATIVE_AI_PROFILE');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 Enriquecer o vocabulário
|
||||||
|
|
||||||
|
Enriquecer o vocabulário com COMMENT ON na tabela gerada no tutorial: [Hospital Risk Admission Prediction with Machine Learning](https://github.com/hoshikawa2/hospital_risk_admission)
|
||||||
|
|
||||||
|
Comentários ajudam o LLM a entender o domínio (descrições de tabelas/colunas).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
COMMENT ON TABLE DATASET_ED_ADMISSION IS
|
||||||
|
'Tabela de pacientes do pronto-socorro / ER patients admission table';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.subject_id IS
|
||||||
|
'Patient ID / ID do paciente (unique identifier)';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.hadm_id IS
|
||||||
|
'Hospital admission ID / ID da internação hospitalar (NULL if not admitted)';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.stay_id IS
|
||||||
|
'ER stay ID / ID da estadia no pronto-socorro';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.intime IS
|
||||||
|
'ER entry timestamp / Data-hora de entrada no pronto-socorro (use EXTRACT(MONTH) for month filter)';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.outtime IS
|
||||||
|
'ER discharge timestamp / Data-hora de saída do pronto-socorro';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.gender IS
|
||||||
|
'Gender (M/F) / Sexo (M/F)';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.race IS
|
||||||
|
'Race/Ethnicity / Raça ou etnia do paciente';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.arrival_transport IS
|
||||||
|
'Arrival transport mode (ambulance, walk) / Forma de chegada (ambulância, caminhada)';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.disposition IS
|
||||||
|
'Disposition after ER (ADMITTED, HOME, etc.) / Destino após atendimento';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.admitted_from_ed IS
|
||||||
|
'Hospitalized from ER (1=yes, 0=no) / Internado a partir do pronto-socorro';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.temperature IS
|
||||||
|
'Body temperature (Celsius) / Temperatura corporal';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.heartrate IS
|
||||||
|
'Heart rate (bpm) / Frequência cardíaca';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.resprate IS
|
||||||
|
'Respiratory rate (breaths/min) / Frequência respiratória';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.o2sat IS
|
||||||
|
'Oxygen saturation (SpO2) / Saturação de oxigênio';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.sbp IS
|
||||||
|
'Systolic blood pressure / Pressão arterial sistólica';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.dbp IS
|
||||||
|
'Diastolic blood pressure / Pressão arterial diastólica';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.n_diagnosis IS
|
||||||
|
'Number of diagnoses / Número de diagnósticos registrados';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN DATASET_ED_ADMISSION.split IS
|
||||||
|
'Data split flag (train, val, test) / Particionamento dos dados';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.6 Criar uma view para NL com nomes claros
|
||||||
|
|
||||||
|
Esta view, mais amigável, facilitará a LLM do banco de dados a entender mais facilmente os campos.
|
||||||
|
|
||||||
|
Vista no schema atual (quando estiver em ADMIN):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE VIEW MEU_USUARIO.NLU_ED_ADMISSION AS
|
||||||
|
SELECT
|
||||||
|
subject_id AS patient_id,
|
||||||
|
hadm_id AS admission_id,
|
||||||
|
stay_id AS er_stay_id,
|
||||||
|
intime AS er_entry_time,
|
||||||
|
outtime AS er_exit_time,
|
||||||
|
gender, race, arrival_transport, disposition,
|
||||||
|
admitted_from_ed AS admitted,
|
||||||
|
temperature, heartrate, resprate, o2sat, sbp, dbp,
|
||||||
|
n_diagnosis, split,
|
||||||
|
EXTRACT(MONTH FROM intime) AS month_num,
|
||||||
|
EXTRACT(YEAR FROM intime) AS year_num
|
||||||
|
FROM ADMIN.DATASET_ED_ADMISSION;
|
||||||
|
```
|
||||||
|
|
||||||
|
E uma view equivalente no schema do usuário (recomendado para consumo pelo app):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- execute no MESMO schema usado pelo SELECT AI (ex.: MEU_USUARIO)
|
||||||
|
CREATE OR REPLACE VIEW MEU_USUARIO.NLU_ED_ADMISSION AS
|
||||||
|
SELECT
|
||||||
|
subject_id AS patient_id,
|
||||||
|
hadm_id AS admission_id,
|
||||||
|
stay_id AS er_stay_id,
|
||||||
|
intime AS er_entry_time,
|
||||||
|
outtime AS er_exit_time,
|
||||||
|
gender, race, arrival_transport, disposition,
|
||||||
|
admitted_from_ed AS admitted, -- 1/0
|
||||||
|
temperature, heartrate, resprate, o2sat, sbp, dbp,
|
||||||
|
n_diagnosis, split,
|
||||||
|
EXTRACT(MONTH FROM intime) AS month_num,
|
||||||
|
EXTRACT(YEAR FROM intime) AS year_num
|
||||||
|
FROM ADMIN.DATASET_ED_ADMISSION;
|
||||||
|
|
||||||
|
COMMENT ON TABLE MEU_USUARIO.NLU_ED_ADMISSION IS 'ER admissions with friendly names for NL queries / Tabela para NL';
|
||||||
|
COMMENT ON COLUMN NLU_ED_ADMISSION.patient_id IS 'Patient ID / ID do paciente';
|
||||||
|
COMMENT ON COLUMN NLU_ED_ADMISSION.admitted IS 'Hospitalized from ER (1=yes, 0=no) / Internado a partir do PS';
|
||||||
|
COMMENT ON COLUMN NLU_ED_ADMISSION.er_entry_time IS 'ER entry timestamp / Entrada no PS';
|
||||||
|
COMMENT ON COLUMN NLU_ED_ADMISSION.month_num IS 'Month number (1..12)';
|
||||||
|
COMMENT ON COLUMN NLU_ED_ADMISSION.year_num IS 'Year';
|
||||||
|
```
|
||||||
|
|
||||||
|
>**Por que?** Nomes como patient_id, admitted, month_num facilitam a tradução NL→SQL e evitam ambiguidade.
|
||||||
|
|
||||||
|
Valide o SELECT AI, executando os comandos abaixo numa mesma sessão:
|
||||||
|
|
||||||
|
### Testar direto no SQL
|
||||||
|
```sql
|
||||||
|
BEGIN
|
||||||
|
EXEC DBMS_CLOUD_AI.SET_PROFILE('OCI_GENERATIVE_AI_PROFILE');
|
||||||
|
SELECT AI 'quantos pacientes no hospital' FROM MEU_USUARIO.NLU_ED_ADMISSION;
|
||||||
|
END;
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 5.7 Preparar o app Flask
|
||||||
|
|
||||||
|
Configure o arquivo [config](./files/config) com os dados da Wallet do Autonomous Database:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"WALLET_PATH": "/caminho/Wallet_ADB",
|
||||||
|
"DB_ALIAS": "oradb_high",
|
||||||
|
"USERNAME": "MEU_USUARIO",
|
||||||
|
"PASSWORD": "SenhaForte123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Baixar o arquivo [requirements.txt](./files/requirements.txt). Instalar dependências:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Testar a aplicação
|
||||||
|
|
||||||
|
Rodar o app [app_select_ai.py](./files/app_select_ai.py):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python app_select_ai.py
|
||||||
|
# abre http://localhost:5001
|
||||||
|
```
|
||||||
|
|
||||||
|
### Acessando a aplicação
|
||||||
|
Abra `http://localhost:5001` e faça perguntas como:
|
||||||
|
- “comparar risco de internacao de pacientes”
|
||||||
|
- “quantos pacientes chegaram no hospital”
|
||||||
|
- “mostrar pacientes e suas pressoes arteriais acima de 120 80”
|
||||||
|
|
||||||
|
### Exportar resultados
|
||||||
|
- **PDF**: botão “Exportar PDF”.
|
||||||
|
- **Excel**: botão “Exportar Excel” (via servidor).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
- [Install OCI-CLI](https://docs.oracle.com/en-us/iaas/Content/API/SDKDocs/cliinstall.htm)
|
||||||
|
- [Download Wallet Database Connection File](https://docs.oracle.com/en/cloud/paas/autonomous-database/serverless/adbsb/connect-download-wallet.html#GUID-DED75E69-C303-409D-9128-5E10ADD47A35)
|
||||||
|
- [About SELECT AI](https://docs.oracle.com/en-us/iaas/autonomous-database-serverless/doc/select-ai.html)
|
||||||
|
- [Hospital Risk Admission Prediction with Machine Learning](https://github.com/hoshikawa2/hospital_risk_admission)
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
- **Author** - Cristiano Hoshikawa (Oracle LAD A-Team Solution Engineer)
|
||||||
|
|||||||
889
files/app_select_ai.py
Normal file
889
files/app_select_ai.py
Normal file
@@ -0,0 +1,889 @@
|
|||||||
|
# app_select_ai.py
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Chat + Dashboard (histórico) para SELECT AI no Autonomous Database
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
from flask import Flask, request, render_template_string, session
|
||||||
|
import oracledb, os, json
|
||||||
|
from flask import send_file, jsonify
|
||||||
|
from io import BytesIO
|
||||||
|
import base64
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
from openpyxl.drawing.image import Image as XLImage
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# CONFIGURAÇÕES DO BANCO
|
||||||
|
# ======================
|
||||||
|
with open("./config", "r") as f:
|
||||||
|
config_data = json.load(f)
|
||||||
|
|
||||||
|
WALLET_PATH = config_data["WALLET_PATH"]
|
||||||
|
DB_ALIAS = config_data["DB_ALIAS"]
|
||||||
|
USERNAME = config_data["USERNAME"]
|
||||||
|
PASSWORD = config_data["PASSWORD"]
|
||||||
|
os.environ["TNS_ADMIN"] = WALLET_PATH
|
||||||
|
|
||||||
|
PROFILE_NAME = "OCI_GENERATIVE_AI_PROFILE"
|
||||||
|
|
||||||
|
def set_select_ai_profile(conn, requested_tag):
|
||||||
|
# Ativa o profile em cada sessão do pool
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("BEGIN DBMS_CLOUD_AI.SET_PROFILE(:p); END;", p=PROFILE_NAME)
|
||||||
|
|
||||||
|
pool = oracledb.create_pool(
|
||||||
|
user=USERNAME,
|
||||||
|
password=PASSWORD,
|
||||||
|
dsn=DB_ALIAS,
|
||||||
|
config_dir=WALLET_PATH,
|
||||||
|
wallet_location=WALLET_PATH,
|
||||||
|
wallet_password=PASSWORD,
|
||||||
|
min=1, max=5, increment=1,
|
||||||
|
session_callback=set_select_ai_profile
|
||||||
|
)
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# APP FLASK
|
||||||
|
# ======================
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = "troque-esta-chave" # necessário p/ sessão (histórico)
|
||||||
|
|
||||||
|
PAGE = """
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-br">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>ER Analytics · Select AI</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script>
|
||||||
|
Chart.defaults.font.size = 11;
|
||||||
|
Chart.defaults.plugins.legend.labels.boxWidth = 10;
|
||||||
|
</script>
|
||||||
|
<!-- Export PDF -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||||
|
<!-- Export Excel (browser) -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xlsx-populate/browser/xlsx-populate.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root { --bg:#0f1115; --card:#141826; --border:#22283a; --muted:#9aa4af; --text:#e5e7eb; --brand:#4f46e5; }
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0; font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial; background:var(--bg); color:var(--text);}
|
||||||
|
|
||||||
|
/* Layout de coluna: topo = histórico, base = input */
|
||||||
|
.page { min-height: 100vh; display:flex; flex-direction:column; }
|
||||||
|
.wrap { max-width:1200px; width:100%; margin:0 auto; padding:16px; flex:1; display:flex; flex-direction:column; gap:12px; }
|
||||||
|
.header{ display:flex; align-items:center; gap:10px; }
|
||||||
|
.logo{ width:34px; height:34px; border-radius:8px; background:linear-gradient(135deg,#4f46e5,#06b6d4); display:flex; align-items:center; justify-content:center; font-weight:800; }
|
||||||
|
.title{ font-size:18px; font-weight:700 }
|
||||||
|
.muted{ color:var(--muted); font-size:12px; }
|
||||||
|
|
||||||
|
.card{ background:var(--card); border:1px solid var(--border); border-radius:12px; padding:14px; }
|
||||||
|
.kpi{ display:flex; gap:8px; flex-wrap:wrap; }
|
||||||
|
.pill{ padding:4px 8px; border:1px solid var(--border); border-radius:999px; font-size:12px; }
|
||||||
|
|
||||||
|
/* Histórico vertical com rolagem; cada item contém gráfico e/ou tabela */
|
||||||
|
#history { flex:1; overflow:auto; display:flex; flex-direction:column; gap:10px; padding-right:4px; max-height: 68vh; }
|
||||||
|
.item { background:#0f1424; border:1px solid var(--border); border-radius:10px; padding:12px; }
|
||||||
|
.sql { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size:12px; color:#cbd5e1; background:#0b0f19; border:1px solid var(--border); border-radius:8px; padding:8px; overflow:auto; }
|
||||||
|
.block { margin-top:8px; }
|
||||||
|
|
||||||
|
table{ width:100%; border-collapse:collapse; font-size:14px; }
|
||||||
|
th,td{ padding:8px; border-bottom:1px solid var(--border); text-align:left; }
|
||||||
|
th{ background:#0f1524; position:sticky; top:0; }
|
||||||
|
|
||||||
|
/* Área de input sempre embaixo */
|
||||||
|
.footer { position: sticky; bottom: 0; background:var(--bg); padding:12px 16px; border-top:1px solid var(--border); }
|
||||||
|
.ask { display:flex; gap:8px; flex-wrap:wrap; }
|
||||||
|
input[type="text"]{ flex:1; min-width:280px; padding:10px 12px; border-radius:10px; border:1px solid var(--border); background:#0c0f19; color:var(--text); }
|
||||||
|
button{ background:var(--brand); color:#fff; border:none; border-radius:10px; padding:10px 14px; font-weight:600; cursor:pointer; }
|
||||||
|
button:hover{ filter:brightness(1.1) }
|
||||||
|
|
||||||
|
.chart-box{
|
||||||
|
height: 220px; /* altura final do gráfico */
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #0b0f19;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px){
|
||||||
|
.chart-box{ height: 180px; } /* menor em telas pequenas */
|
||||||
|
}
|
||||||
|
.item { max-width: 100%; } /* já está */
|
||||||
|
.hist .item { max-width: 520px; } /* se usar histórico horizontal */
|
||||||
|
|
||||||
|
.toolbar{ display:flex; gap:6px; flex-wrap:wrap; margin:6px 0 6px; }
|
||||||
|
.pill{ background:#0e1420; border:1px solid var(--border); color:#cbd5e1;
|
||||||
|
padding:6px 10px; border-radius:999px; font-size:12px; cursor:pointer; }
|
||||||
|
.pill:hover{ border-color:#3a4258; }
|
||||||
|
.chart-box{
|
||||||
|
height: 220px; width: 100%;
|
||||||
|
border:1px solid var(--border); border-radius:10px;
|
||||||
|
background:#0b0f19; padding:8px;
|
||||||
|
}
|
||||||
|
@media (max-width:640px){ .chart-box{ height:180px; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">ER</div>
|
||||||
|
<div>
|
||||||
|
<div class="title">ER Analytics · Select AI</div>
|
||||||
|
<div class="muted">Respostas e gráficos no topo; pergunta no rodapé. A última resposta fica visível automaticamente.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if timeline %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi">
|
||||||
|
<span class="pill">Conexão: <b>{{ db_alias }}</b></span>
|
||||||
|
<span class="pill">Usuário: <b>{{ session_user }}</b></span>
|
||||||
|
<span class="pill">Profile AI: <b>{{ profile or '—' }}</b></span>
|
||||||
|
|
||||||
|
<!-- Botões de export -->
|
||||||
|
<span class="pill" style="cursor:auto; border:none;">|</span>
|
||||||
|
<button type="button" class="pill" onclick="exportPDF()" title="Exporta todas as respostas (com gráficos e tabelas) para PDF">Exportar PDF</button>
|
||||||
|
<button type="button" class="pill" onclick="exportExcelServer()">Exportar Excel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="history">
|
||||||
|
{% for item in timeline %}
|
||||||
|
<div class="item">
|
||||||
|
<div><b>Pergunta:</b> {{ item.prompt }}</div>
|
||||||
|
{% if item.sql %}<div class="block"><div class="sql">{{ item.sql }}</div></div>{% endif %}
|
||||||
|
|
||||||
|
{% if item.chart %}
|
||||||
|
<div class="toolbar">
|
||||||
|
<span class="pill" onclick="render_{{ loop.index0 }}('bar')">Barras</span>
|
||||||
|
<span class="pill" onclick="render_{{ loop.index0 }}('line')">Linhas</span>
|
||||||
|
<span class="pill" onclick="render_{{ loop.index0 }}('pie')">Pizza</span>
|
||||||
|
{% if item.table %}
|
||||||
|
<span class="pill" onclick="toggleTable_{{ loop.index0 }}()">Mostrar/ocultar Tabela</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block chart-box">
|
||||||
|
<canvas id="chart_{{ loop.index0 }}"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const DATA_{{ loop.index0 }} = {
|
||||||
|
type: '{{ item.chart["type"] }}',
|
||||||
|
labels: {{ item.chart["labels"]|tojson }},
|
||||||
|
values: {{ item.chart["values"]|tojson }},
|
||||||
|
seriesLabel: '{{ item.chart["seriesLabel"] }}',
|
||||||
|
title: '{{ item.chart["title"] }}',
|
||||||
|
xLabel: '{{ item.chart["xLabel"] }}',
|
||||||
|
yLabel: '{{ item.chart["yLabel"] }}'
|
||||||
|
};
|
||||||
|
|
||||||
|
let ch_{{ loop.index0 }} = null;
|
||||||
|
|
||||||
|
function makeChart_{{ loop.index0 }}(kind){
|
||||||
|
const ctx = document.getElementById('chart_{{ loop.index0 }}').getContext('2d');
|
||||||
|
if (ch_{{ loop.index0 }}) ch_{{ loop.index0 }}.destroy();
|
||||||
|
ch_{{ loop.index0 }} = new Chart(ctx, {
|
||||||
|
type: kind,
|
||||||
|
data: {
|
||||||
|
labels: DATA_{{ loop.index0 }}.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: DATA_{{ loop.index0 }}.seriesLabel,
|
||||||
|
data: DATA_{{ loop.index0 }}.values,
|
||||||
|
borderWidth: 1,
|
||||||
|
pointRadius: 2
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
layout: { padding: 4 },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, labels: { boxWidth: 10, font: { size: 10 } } },
|
||||||
|
title: { display: true, text: DATA_{{ loop.index0 }}.title, font: { size: 12 } },
|
||||||
|
tooltip:{ bodyFont: { size: 11 }, titleFont: { size: 11 } }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { title: { display: true, text: DATA_{{ loop.index0 }}.xLabel, font: { size: 11 } },
|
||||||
|
ticks: { font: { size: 10 }, maxRotation: 0, autoSkip: true } },
|
||||||
|
y: { title: { display: true, text: DATA_{{ loop.index0 }}.yLabel, font: { size: 11 } },
|
||||||
|
ticks: { font: { size: 10 } }, beginAtZero: true, grace: "5%" }
|
||||||
|
},
|
||||||
|
elements: { line: { tension: 0.2 } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// garante que a última resposta fica visível após render
|
||||||
|
setTimeout(scrollHistoryBottom, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// expõe funções por-card
|
||||||
|
window.render_{{ loop.index0 }} = makeChart_{{ loop.index0 }};
|
||||||
|
{% if item.table %}
|
||||||
|
window.toggleTable_{{ loop.index0 }} = function(){
|
||||||
|
const el = document.getElementById('table_{{ loop.index0 }}');
|
||||||
|
if (!el) return;
|
||||||
|
el.style.display = (el.style.display === 'none') ? '' : 'none';
|
||||||
|
setTimeout(scrollHistoryBottom, 40);
|
||||||
|
};
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
// inicial: usa o tipo sugerido pelo backend
|
||||||
|
makeChart_{{ loop.index0 }}(DATA_{{ loop.index0 }}.type);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if item.table %}
|
||||||
|
<div id="table_{{ loop.index0 }}" class="block" style="border:1px solid var(--border); border-radius:10px; overflow:auto; max-height:40vh;">
|
||||||
|
<table>
|
||||||
|
<thead><tr>{% for h in item.table.headers %}<th>{{ h }}</th>{% endfor %}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in item.table.rows %}
|
||||||
|
<tr>{% for c in r %}<td>{{ c }}</td>{% endfor %}</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<!-- ⬇️ marcador fixo de fim -->
|
||||||
|
<div id="history-bottom"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rodapé com input SEMPRE embaixo -->
|
||||||
|
<div class="footer">
|
||||||
|
<form class="ask" method="post" onsubmit="markScrollToBottom()">
|
||||||
|
<input type="text" name="frase" placeholder="Faça sua pergunta… (Enter para enviar)" required>
|
||||||
|
<input type="text" name="tabela" value="{{ default_table }}">
|
||||||
|
<button type="submit">Enviar</button>
|
||||||
|
</form>
|
||||||
|
<div class="muted" style="margin-top:6px">Dica: cite colunas (ex.: <code>admitted</code>, <code>month_num</code>, <code>year_num</code>) para respostas mais precisas.</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const frase = document.querySelector('input[name="frase"]');
|
||||||
|
frase.addEventListener("keydown", (e) => {
|
||||||
|
if(e.key === "Enter"){
|
||||||
|
e.preventDefault();
|
||||||
|
frase.form.requestSubmit(); // envia o form
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function scrollHistoryBottom(smooth=true){
|
||||||
|
const end = document.getElementById('history-bottom');
|
||||||
|
if (end) end.scrollIntoView({behavior: smooth ? 'smooth' : 'auto', block: 'end'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marca a intenção de rolar após o POST (reload da página)
|
||||||
|
function markScrollToBottom(){
|
||||||
|
try { localStorage.setItem('__scrollToBottom', '1'); } catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rola quando a página carrega (e re-rola depois que gráficos expandirem)
|
||||||
|
function settleAndScroll(){
|
||||||
|
scrollHistoryBottom(false); // imediato
|
||||||
|
setTimeout(scrollHistoryBottom, 80); // após layout inicial
|
||||||
|
setTimeout(scrollHistoryBottom, 250);// após Chart.js desenhar
|
||||||
|
setTimeout(scrollHistoryBottom, 600);// salvaguarda final
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executa no load
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
// se veio de um submit, respeite a marca
|
||||||
|
let must = false;
|
||||||
|
try { must = localStorage.getItem('__scrollToBottom') === '1'; } catch(e){}
|
||||||
|
if (must) {
|
||||||
|
try { localStorage.removeItem('__scrollToBottom'); } catch(e){}
|
||||||
|
settleAndScroll();
|
||||||
|
} else {
|
||||||
|
// mesmo sem submit, mantenha a última resposta à vista
|
||||||
|
settleAndScroll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
function _grabTableData(itemEl){
|
||||||
|
const table = itemEl.querySelector('table');
|
||||||
|
if(!table) return null;
|
||||||
|
const headers = Array.from(table.querySelectorAll('thead th')).map(th => th.textContent.trim());
|
||||||
|
const rows = Array.from(table.querySelectorAll('tbody tr')).map(tr =>
|
||||||
|
Array.from(tr.querySelectorAll('td')).map(td => td.textContent)
|
||||||
|
);
|
||||||
|
return { headers, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _grabQuestion(itemEl){
|
||||||
|
const b = itemEl.querySelector('div>b');
|
||||||
|
if(b && b.parentElement){
|
||||||
|
return b.parentElement.textContent.replace(/^Pergunta:\s*/,'').trim();
|
||||||
|
}
|
||||||
|
return "Pergunta";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _grabSQL(itemEl){
|
||||||
|
const sqlEl = itemEl.querySelector('.sql');
|
||||||
|
return sqlEl ? sqlEl.textContent : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportExcelServer(){
|
||||||
|
const items = Array.from(document.querySelectorAll('#history .item'));
|
||||||
|
if(items.length === 0){
|
||||||
|
alert('Não há respostas para exportar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// monta payload
|
||||||
|
const payload = { items: [] };
|
||||||
|
for(const item of items){
|
||||||
|
const question = _grabQuestion(item);
|
||||||
|
const sql = _grabSQL(item);
|
||||||
|
const table = _grabTableData(item);
|
||||||
|
const canvas = item.querySelector('canvas');
|
||||||
|
|
||||||
|
let chartPng = null;
|
||||||
|
if(canvas){
|
||||||
|
try{
|
||||||
|
chartPng = canvas.toDataURL('image/png'); // inclui prefixo data:
|
||||||
|
}catch(e){
|
||||||
|
chartPng = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.items.push({
|
||||||
|
question, sql, table, chartPng
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// envia para o servidor
|
||||||
|
const res = await fetch("/export/xlsx", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if(!res.ok){
|
||||||
|
const t = await res.text();
|
||||||
|
console.error('Falha no export:', t);
|
||||||
|
alert('Falha ao gerar Excel no servidor.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// baixa o arquivo
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "respostas-select-ai.xlsx";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(()=>URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
// Utilitário para baixar Blob
|
||||||
|
function downloadBlob(blob, filename){
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = filename; a.click();
|
||||||
|
setTimeout(()=>URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Exportar PDF (com charts + tabelas como na tela) ============
|
||||||
|
async function exportPDF(){
|
||||||
|
const { jsPDF } = window.jspdf;
|
||||||
|
const pdf = new jsPDF('p','mm','a4');
|
||||||
|
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||||
|
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||||
|
const margin = 10;
|
||||||
|
const maxW = pageWidth - margin*2;
|
||||||
|
|
||||||
|
const items = Array.from(document.querySelectorAll('#history .item'));
|
||||||
|
if(items.length === 0){ alert('Não há respostas para exportar.'); return; }
|
||||||
|
|
||||||
|
for(let i=0;i<items.length;i++){
|
||||||
|
const el = items[i];
|
||||||
|
// usa html2canvas para “fotografar” o card, incluindo canvas e tabelas
|
||||||
|
const canvas = await html2canvas(el, {backgroundColor: '#0f1115', scale: 2, useCORS: true});
|
||||||
|
const imgData = canvas.toDataURL('image/png');
|
||||||
|
// dimensiona para caber na página
|
||||||
|
const imgW = maxW;
|
||||||
|
const imgH = canvas.height * (imgW / canvas.width);
|
||||||
|
|
||||||
|
// se altura maior que a página, escala para caber verticalmente
|
||||||
|
const effW = imgW;
|
||||||
|
const effH = Math.min(imgH, pageHeight - margin*2);
|
||||||
|
const scale = Math.min(imgW / canvas.width, (pageHeight - margin*2) / canvas.height);
|
||||||
|
const drawW = canvas.width * scale;
|
||||||
|
const drawH = canvas.height * scale;
|
||||||
|
|
||||||
|
if(i>0) pdf.addPage();
|
||||||
|
pdf.addImage(imgData, 'PNG', margin, margin, drawW, drawH);
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.save('respostas-select-ai.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Exportar Excel (uma planilha por resposta; gráfico como imagem) ============
|
||||||
|
// Helper para baixar Blob
|
||||||
|
function downloadBlob(blob, filename){
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = filename; a.click();
|
||||||
|
setTimeout(()=>URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: insere imagem tentando múltiplas assinaturas do XlsxPopulate
|
||||||
|
async function insertImage(wb, sheet, base64Png, topLeftCell, width, height){
|
||||||
|
// 1) Tentativa com 'base64' + 'anchor'
|
||||||
|
try{
|
||||||
|
await sheet.addImage({
|
||||||
|
base64: base64Png, // sem prefixo data:
|
||||||
|
name: 'chart',
|
||||||
|
anchor: topLeftCell, // ex.: 'A12'
|
||||||
|
width, height
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}catch(e1){
|
||||||
|
// 2) Tentativa com 'image' (dataURL completo) + 'topLeftCell'
|
||||||
|
try{
|
||||||
|
const dataUrl = 'data:image/png;base64,' + base64Png;
|
||||||
|
await sheet.addImage({
|
||||||
|
image: dataUrl,
|
||||||
|
topLeftCell: topLeftCell,
|
||||||
|
width, height
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}catch(e2){
|
||||||
|
// 3) Via workbook.addImage() + sheet.addImage({image})
|
||||||
|
try{
|
||||||
|
const img = wb.addImage({ base64: base64Png, extension: 'png' });
|
||||||
|
await sheet.addImage({
|
||||||
|
image: img,
|
||||||
|
topLeftCell: topLeftCell,
|
||||||
|
width, height
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}catch(e3){
|
||||||
|
console.warn('Falha ao inserir imagem no Excel:', {e1, e2, e3});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exporta TODAS as respostas para Excel: dados + gráfico (PNG)
|
||||||
|
async function exportExcel(){
|
||||||
|
if (!window.XlsxPopulate) {
|
||||||
|
alert("Biblioteca XlsxPopulate não carregada.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.from(document.querySelectorAll('#history .item'));
|
||||||
|
if(items.length === 0){
|
||||||
|
alert('Não há respostas para exportar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wb = await XlsxPopulate.fromBlankAsync();
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
const name = ("Resp " + (i+1)).slice(0, 31);
|
||||||
|
const sheet = wb.addSheet(name);
|
||||||
|
|
||||||
|
// --- Pergunta ---
|
||||||
|
let question = 'Resposta ' + (i+1);
|
||||||
|
const qTitle = item.querySelector('div>b');
|
||||||
|
if (qTitle && qTitle.parentElement) {
|
||||||
|
question = qTitle.parentElement.textContent.replace(/^Pergunta:\s*/,'').trim();
|
||||||
|
}
|
||||||
|
sheet.cell('A1').value('Pergunta:').style({ bold:true });
|
||||||
|
sheet.cell('B1').value(question);
|
||||||
|
sheet.row(1).height(22);
|
||||||
|
|
||||||
|
// --- SQL (se houver) ---
|
||||||
|
const sqlEl = item.querySelector('.sql');
|
||||||
|
if (sqlEl){
|
||||||
|
sheet.cell('A2').value('SQL:').style({ bold:true });
|
||||||
|
sheet.cell('B2').value(sqlEl.textContent);
|
||||||
|
sheet.column('B').width(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tabela (se houver) ---
|
||||||
|
let nextRow = 4;
|
||||||
|
const tableEl = item.querySelector('table');
|
||||||
|
if (tableEl){
|
||||||
|
const headers = Array.from(tableEl.querySelectorAll('thead th')).map(th => th.textContent.trim());
|
||||||
|
const rows = Array.from(tableEl.querySelectorAll('tbody tr')).map(tr =>
|
||||||
|
Array.from(tr.querySelectorAll('td')).map(td => td.textContent)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (headers.length){
|
||||||
|
const lastColLetter = String.fromCharCode(64 + headers.length); // A..Z (ok p/ até 26 colunas)
|
||||||
|
sheet.range(`A${nextRow}:${lastColLetter}${nextRow}`).value([headers]).style({ bold:true, fill: 'EFEFEF' });
|
||||||
|
if (rows.length){
|
||||||
|
sheet.range(`A${nextRow+1}:${lastColLetter}${nextRow+rows.length}`).value(rows);
|
||||||
|
}
|
||||||
|
// larguras aproximadas
|
||||||
|
headers.forEach((h, idx) => sheet.column(idx+1).width(Math.max(12, Math.min(40, h.length + 4))));
|
||||||
|
nextRow = nextRow + rows.length + 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Gráfico (se houver) ---
|
||||||
|
const canvasEl = item.querySelector('canvas');
|
||||||
|
let embedded = false;
|
||||||
|
|
||||||
|
if (canvasEl){
|
||||||
|
try{
|
||||||
|
const dataUrl = canvasEl.toDataURL('image/png');
|
||||||
|
const base64 = dataUrl.split(',')[1]; // remove prefixo
|
||||||
|
embedded = await insertImage(wb, sheet, base64, `A${nextRow}`, 640, 300);
|
||||||
|
if (embedded) nextRow += 18;
|
||||||
|
}catch(err){
|
||||||
|
console.warn('Falha ao capturar canvas:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: se não tinha canvas ou inserção falhou, captura o card inteiro (html2canvas)
|
||||||
|
if (!embedded){
|
||||||
|
try{
|
||||||
|
const snap = await html2canvas(item, { backgroundColor: '#ffffff', scale: 2, useCORS: true });
|
||||||
|
const base64 = snap.toDataURL('image/png').split(',')[1];
|
||||||
|
await insertImage(wb, sheet, base64, `A${nextRow}`, 640, 360);
|
||||||
|
nextRow += 20;
|
||||||
|
}catch(err2){
|
||||||
|
console.warn(`Sem gráfico na aba ${name} (seguindo só com dados).`, err2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Opcional) renomeia a primeira planilha vazia
|
||||||
|
try { wb.sheet(0).name('Resumo'); } catch(e){}
|
||||||
|
|
||||||
|
const blob = await wb.outputAsync();
|
||||||
|
downloadBlob(blob, 'respostas-select-ai.xlsx');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Falha ao gerar Excel:', e);
|
||||||
|
alert('Falha ao gerar Excel. Veja o console para detalhes.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _auto_width(ws, start_col, end_col, extra=2):
|
||||||
|
for col_idx in range(start_col, end_col+1):
|
||||||
|
col_letter = get_column_letter(col_idx)
|
||||||
|
max_len = 0
|
||||||
|
for cell in ws[col_letter]:
|
||||||
|
try:
|
||||||
|
max_len = max(max_len, len(str(cell.value)) if cell.value is not None else 0)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
ws.column_dimensions[col_letter].width = min(50, max(12, max_len + extra))
|
||||||
|
|
||||||
|
@app.route("/export/xlsx", methods=["POST"])
|
||||||
|
def export_xlsx():
|
||||||
|
"""
|
||||||
|
Espera JSON com uma lista 'items'. Cada item:
|
||||||
|
{
|
||||||
|
"question": str,
|
||||||
|
"sql": str|None,
|
||||||
|
"table": { "headers":[...], "rows":[[...], ...] } | None,
|
||||||
|
"chartPng": "data:image/png;base64,..." | None
|
||||||
|
}
|
||||||
|
Retorna um arquivo XLSX com 1 aba por resposta.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = request.get_json(force=True)
|
||||||
|
items = payload.get("items", [])
|
||||||
|
if not items:
|
||||||
|
return jsonify({"error":"sem itens"}), 400
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
# use a folha ativa como “Resumo”
|
||||||
|
ws0 = wb.active
|
||||||
|
ws0.title = "Resumo"
|
||||||
|
ws0["A1"] = "Export gerado pelo ER Analytics · Select AI"
|
||||||
|
ws0["A2"] = f"Total de respostas: {len(items)}"
|
||||||
|
|
||||||
|
for i, it in enumerate(items, start=1):
|
||||||
|
title = f"Resp {i}"
|
||||||
|
ws = wb.create_sheet(title[:31])
|
||||||
|
|
||||||
|
# Pergunta
|
||||||
|
ws["A1"] = "Pergunta:"
|
||||||
|
ws["A1"].font = ws["A1"].font.copy(bold=True)
|
||||||
|
ws["B1"] = it.get("question") or f"Resposta {i}"
|
||||||
|
ws.row_dimensions[1].height = 22
|
||||||
|
|
||||||
|
# SQL
|
||||||
|
sql = (it.get("sql") or "").strip()
|
||||||
|
if sql:
|
||||||
|
ws["A2"] = "SQL:"
|
||||||
|
ws["A2"].font = ws["A2"].font.copy(bold=True)
|
||||||
|
ws["B2"] = sql
|
||||||
|
|
||||||
|
row = 4
|
||||||
|
|
||||||
|
# Tabela
|
||||||
|
table = it.get("table")
|
||||||
|
if table and table.get("headers"):
|
||||||
|
headers = table["headers"]
|
||||||
|
rows = table.get("rows", [])
|
||||||
|
for j, h in enumerate(headers, start=1):
|
||||||
|
cell = ws.cell(row=row, column=j, value=h)
|
||||||
|
cell.font = cell.font.copy(bold=True)
|
||||||
|
cell.fill = cell.fill.copy()
|
||||||
|
row += 1
|
||||||
|
for r in rows:
|
||||||
|
for j, val in enumerate(r, start=1):
|
||||||
|
ws.cell(row=row, column=j, value=val)
|
||||||
|
row += 1
|
||||||
|
# largura de colunas
|
||||||
|
_auto_width(ws, 1, len(headers))
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# Gráfico como PNG (se vier)
|
||||||
|
chart_png = it.get("chartPng")
|
||||||
|
if chart_png:
|
||||||
|
try:
|
||||||
|
# aceita dataURL com prefixo
|
||||||
|
if "," in chart_png:
|
||||||
|
chart_png = chart_png.split(",", 1)[1]
|
||||||
|
img_bytes = base64.b64decode(chart_png)
|
||||||
|
# normaliza via PIL (corrige metadados)
|
||||||
|
pil = PILImage.open(BytesIO(img_bytes)).convert("RGBA")
|
||||||
|
buf = BytesIO()
|
||||||
|
pil.save(buf, format="PNG")
|
||||||
|
buf.seek(0)
|
||||||
|
xl_img = XLImage(buf)
|
||||||
|
anchor_cell = f"A{row}"
|
||||||
|
ws.add_image(xl_img, anchor_cell)
|
||||||
|
# empurra linhas para não sobrepor texto
|
||||||
|
row += 20
|
||||||
|
except Exception as e:
|
||||||
|
# segue sem o gráfico
|
||||||
|
pass
|
||||||
|
|
||||||
|
# remove folha “Sheet” se sobrar (caso libs criem extra)
|
||||||
|
for sh in list(wb.sheetnames):
|
||||||
|
if sh.lower().startswith("sheet") and sh != ws0.title:
|
||||||
|
try:
|
||||||
|
del wb[sh]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
out = BytesIO()
|
||||||
|
wb.save(out)
|
||||||
|
out.seek(0)
|
||||||
|
return send_file(
|
||||||
|
out,
|
||||||
|
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
as_attachment=True,
|
||||||
|
download_name="respostas-select-ai.xlsx",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
def _to_float(x):
|
||||||
|
if x is None: return None
|
||||||
|
s = str(x).strip().replace(',', '.')
|
||||||
|
if s.endswith('%'): s = s[:-1]
|
||||||
|
try:
|
||||||
|
return float(s)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def build_chart(headers, rows):
|
||||||
|
"""
|
||||||
|
Gera metadados de gráfico + eixos/legenda:
|
||||||
|
- 1x1 percentual -> pizza (Internados vs Não)
|
||||||
|
- 2 colunas (cat, num) -> barras
|
||||||
|
- 3 colunas com (categoria, count, percentage) -> usa percentage; senão count
|
||||||
|
- 3 colunas com temporal (col contém MONTH/YEAR/DATE) -> linha
|
||||||
|
Retorna dict {type, labels, values, seriesLabel, title, xLabel, yLabel} ou None
|
||||||
|
"""
|
||||||
|
if not headers or not rows: return None
|
||||||
|
H = [h.upper() for h in headers]
|
||||||
|
|
||||||
|
# 1) 1x1 percentual
|
||||||
|
if len(headers) == 1 and len(rows) == 1:
|
||||||
|
v = _to_float(rows[0][0])
|
||||||
|
if v is None: return None
|
||||||
|
pct = v*100 if 0 <= v <= 1 else v
|
||||||
|
if 0 <= pct <= 100:
|
||||||
|
return {
|
||||||
|
"type": "pie",
|
||||||
|
"labels": ["Internados", "Não internados"],
|
||||||
|
"values": [round(pct,2), round(100-pct,2)],
|
||||||
|
"seriesLabel": "Percentual",
|
||||||
|
"title": f"{headers[0]}",
|
||||||
|
"xLabel": "Status",
|
||||||
|
"yLabel": "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2) 2 colunas: categoria × valor
|
||||||
|
if len(headers) == 2:
|
||||||
|
labels, values = [], []
|
||||||
|
for r in rows:
|
||||||
|
labels.append(str(r[0]))
|
||||||
|
vf = _to_float(r[1])
|
||||||
|
if vf is None: return None
|
||||||
|
values.append(vf)
|
||||||
|
return {
|
||||||
|
"type": "bar",
|
||||||
|
"labels": labels,
|
||||||
|
"values": values,
|
||||||
|
"seriesLabel": headers[1],
|
||||||
|
"title": f"{headers[1]} por {headers[0]}",
|
||||||
|
"xLabel": headers[0],
|
||||||
|
"yLabel": headers[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3) 3 colunas: temporal ou categoria + (count, percentage)
|
||||||
|
if len(headers) == 3:
|
||||||
|
# 3a) temporal
|
||||||
|
idx_time = next((i for i,h in enumerate(H) if any(k in h for k in ["DATE","DATA","MONTH","YEAR","TIME"])), None)
|
||||||
|
if idx_time is not None:
|
||||||
|
idx_val = 2
|
||||||
|
labels, values = [], []
|
||||||
|
for r in rows:
|
||||||
|
labels.append(str(r[idx_time]))
|
||||||
|
vf = _to_float(r[idx_val])
|
||||||
|
if vf is None: return None
|
||||||
|
values.append(vf)
|
||||||
|
return {
|
||||||
|
"type": "line",
|
||||||
|
"labels": labels,
|
||||||
|
"values": values,
|
||||||
|
"seriesLabel": headers[idx_val],
|
||||||
|
"title": f"{headers[idx_val]} por {headers[idx_time]}",
|
||||||
|
"xLabel": headers[idx_time],
|
||||||
|
"yLabel": headers[idx_val]
|
||||||
|
}
|
||||||
|
# 3b) categoria + count + percentage
|
||||||
|
idx_pct = next((i for i,h in enumerate(H) if "PERCENT" in h), None)
|
||||||
|
idx_cnt = next((i for i,h in enumerate(H) if any(k in h for k in ["COUNT","NUMBER"])), None)
|
||||||
|
idx_cat = 0
|
||||||
|
idx_val = idx_pct if idx_pct is not None else idx_cnt
|
||||||
|
if idx_val is not None:
|
||||||
|
labels, values = [], []
|
||||||
|
for r in rows:
|
||||||
|
labels.append(str(r[idx_cat]))
|
||||||
|
vf = _to_float(r[idx_val])
|
||||||
|
if vf is None: return None
|
||||||
|
values.append(vf)
|
||||||
|
ylab = headers[idx_val] + (" (%)" if idx_val == idx_pct else "")
|
||||||
|
return {
|
||||||
|
"type": "bar" if idx_val == idx_cnt else "pie",
|
||||||
|
"labels": labels,
|
||||||
|
"values": values,
|
||||||
|
"seriesLabel": headers[idx_val],
|
||||||
|
"title": f"{headers[idx_val]} por {headers[idx_cat]}",
|
||||||
|
"xLabel": headers[idx_cat],
|
||||||
|
"yLabel": ylab
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def format_table(headers, rows, limit=500):
|
||||||
|
if not headers: return None
|
||||||
|
rows = rows if len(rows) <= limit else rows[:limit]
|
||||||
|
return {"headers": headers, "rows": rows}
|
||||||
|
|
||||||
|
def run_select_ai(nl_prompt, table_name):
|
||||||
|
frase_segura = nl_prompt.replace("'", "''")
|
||||||
|
sql = f"SELECT AI '{frase_segura}' FROM {table_name}"
|
||||||
|
with pool.acquire() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("select user, dbms_cloud_ai.get_profile() from dual")
|
||||||
|
user_, profile_ = cur.fetchone()
|
||||||
|
cur.execute(sql)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
headers = [d[0] for d in cur.description] if cur.description else []
|
||||||
|
return sql, headers, rows, user_, profile_
|
||||||
|
|
||||||
|
def ensure_timeline():
|
||||||
|
if "timeline" not in session:
|
||||||
|
session["timeline"] = []
|
||||||
|
return session["timeline"]
|
||||||
|
|
||||||
|
@app.route("/", methods=["GET","POST"])
|
||||||
|
def index():
|
||||||
|
default_table = "MEU_USUARIO.NLU_ED_ADMISSION"
|
||||||
|
timeline = ensure_timeline()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
frase = request.form["frase"].strip()
|
||||||
|
tabela = request.form.get("tabela", default_table).strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
sql, headers, rows, user_, profile_ = run_select_ai(frase, tabela)
|
||||||
|
table = format_table(headers, rows)
|
||||||
|
chart = build_chart(headers, rows)
|
||||||
|
|
||||||
|
# empilha no histórico (mantém últimos 10)
|
||||||
|
timeline.append({
|
||||||
|
"prompt": frase,
|
||||||
|
"sql": sql,
|
||||||
|
"table": table,
|
||||||
|
"chart": chart
|
||||||
|
})
|
||||||
|
if len(timeline) > 10:
|
||||||
|
timeline[:] = timeline[-10:]
|
||||||
|
session["timeline"] = timeline
|
||||||
|
|
||||||
|
return render_template_string(
|
||||||
|
PAGE,
|
||||||
|
timeline=timeline,
|
||||||
|
default_table=default_table,
|
||||||
|
db_alias=DB_ALIAS,
|
||||||
|
session_user=user_,
|
||||||
|
profile=profile_
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
timeline.append({
|
||||||
|
"prompt": frase,
|
||||||
|
"sql": None,
|
||||||
|
"table": {"headers": ["Erro"], "rows": [[str(e)]]},
|
||||||
|
"chart": None
|
||||||
|
})
|
||||||
|
if len(timeline) > 10:
|
||||||
|
timeline[:] = timeline[-10:]
|
||||||
|
session["timeline"] = timeline
|
||||||
|
return render_template_string(
|
||||||
|
PAGE,
|
||||||
|
timeline=timeline,
|
||||||
|
default_table=default_table,
|
||||||
|
db_alias=DB_ALIAS,
|
||||||
|
session_user=USERNAME,
|
||||||
|
profile=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# GET
|
||||||
|
return render_template_string(
|
||||||
|
PAGE,
|
||||||
|
timeline=timeline,
|
||||||
|
default_table=default_table,
|
||||||
|
db_alias=DB_ALIAS,
|
||||||
|
session_user=USERNAME,
|
||||||
|
profile=None
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(debug=True, port=5001)
|
||||||
6
files/config
Normal file
6
files/config
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"WALLET_PATH": "./Wallet_ORADB23ai",
|
||||||
|
"DB_ALIAS": "oradb23ai_high",
|
||||||
|
"USERNAME": "admin",
|
||||||
|
"PASSWORD": "*********"
|
||||||
|
}
|
||||||
4
files/requirements.txt
Normal file
4
files/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
flask
|
||||||
|
oracledb
|
||||||
|
openpyxl
|
||||||
|
pillow
|
||||||
Reference in New Issue
Block a user