diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..7d9a8e5 --- /dev/null +++ b/.idea/.gitignore @@ -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/ diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..919ce1f --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..a55e7a1 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..e5af510 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..2089778 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/select_ai.iml b/.idea/select_ai.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/select_ai.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.oca/custom_code_review_guidelines.txt b/.oca/custom_code_review_guidelines.txt new file mode 100644 index 0000000..a0a3b63 --- /dev/null +++ b/.oca/custom_code_review_guidelines.txt @@ -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. +# +# *Comment: +# Category: Minor +# Issue: Use streams instead of a loop for better readability. +# Code Block: +# +# ```java +# // Calculate squares of numbers +# List squares = new ArrayList<>(); +# for (int number : numbers) { +# squares.add(number * number); +# } +# ``` +# Recommendation: +# +# ```java +# // Calculate squares of numbers +# List squares = Arrays.stream(numbers) +# .map(n -> n * n) // Map each number to its square +# .toList(); +# ``` +# diff --git a/README.md b/README.md index 69dafe2..63f9488 100644 --- a/README.md +++ b/README.md @@ -1 +1,394 @@ -# select_ai \ No newline at end of file +# 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 ` — 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). + +![img_1.png](img_1.png) + +### run_select_ai +- Monta a sentença **`SELECT AI 'pergunta' FROM `**. +- Executa e retorna **SQL gerado**, **headers/rows** e usuário/perfil ativos. + +![img_2.png](img_2.png) + +### build_chart e format_table +- `build_chart`: heurísticas para inferir gráfico (pizza, barras, linha). +- `format_table`: limita registros e devolve `{headers, rows}`. + +![img_3.png](img_3.png) + + +### 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). + +--- + +![img.png](img.png) + +## 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) diff --git a/files/app_select_ai.py b/files/app_select_ai.py new file mode 100644 index 0000000..f3ab155 --- /dev/null +++ b/files/app_select_ai.py @@ -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 = """ + + + + +ER Analytics · Select AI + + + + + + + + + + + +
+
+
+ +
+
ER Analytics · Select AI
+
Respostas e gráficos no topo; pergunta no rodapé. A última resposta fica visível automaticamente.
+
+
+ + {% if timeline %} +
+
+ Conexão: {{ db_alias }} + Usuário: {{ session_user }} + Profile AI: {{ profile or '—' }} + + + | + + +
+
+ +
+ {% for item in timeline %} +
+
Pergunta: {{ item.prompt }}
+ {% if item.sql %}
{{ item.sql }}
{% endif %} + + {% if item.chart %} +
+ Barras + Linhas + Pizza + {% if item.table %} + Mostrar/ocultar Tabela + {% endif %} +
+ +
+ +
+ + + {% endif %} + + {% if item.table %} +
+ + {% for h in item.table.headers %}{% endfor %} + + {% for r in item.table.rows %} + {% for c in r %}{% endfor %} + {% endfor %} + +
{{ h }}
{{ c }}
+
+ {% endif %} +
+ {% endfor %} + +
+
+ {% endif %} +
+ + + + +
+ + + + + +""" + +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) \ No newline at end of file diff --git a/files/config b/files/config new file mode 100644 index 0000000..16723d9 --- /dev/null +++ b/files/config @@ -0,0 +1,6 @@ +{ + "WALLET_PATH": "./Wallet_ORADB23ai", + "DB_ALIAS": "oradb23ai_high", + "USERNAME": "admin", + "PASSWORD": "*********" +} \ No newline at end of file diff --git a/files/requirements.txt b/files/requirements.txt new file mode 100644 index 0000000..24b2e1b --- /dev/null +++ b/files/requirements.txt @@ -0,0 +1,4 @@ +flask +oracledb +openpyxl +pillow diff --git a/img.png b/img.png new file mode 100644 index 0000000..cadf3fe Binary files /dev/null and b/img.png differ diff --git a/img_1.png b/img_1.png new file mode 100644 index 0000000..34430b1 Binary files /dev/null and b/img_1.png differ diff --git a/img_2.png b/img_2.png new file mode 100644 index 0000000..b6556d8 Binary files /dev/null and b/img_2.png differ diff --git a/img_3.png b/img_3.png new file mode 100644 index 0000000..6ff7ba7 Binary files /dev/null and b/img_3.png differ