tuliodisanto's picture
Upload 2 files
f2dfda4 verified
# app.py (Versão Final com a Lógica de Feedback Original Restaurada)
###################################################################################################
#
# RESUMO DAS CORREÇÕES E MELHORIAS:
#
# 1. LÓGICA DE FEEDBACK RESTAURADA (CORREÇÃO PRINCIPAL):
# - A funcionalidade de salvar o feedback do usuário foi reescrita para espelhar
# EXATAMENTE a lógica do arquivo `app (1).py` que você forneceu, que já funcionava.
# - Usa a flag global `DATA_HAS_CHANGED` para rastrear modificações.
# - A rota `/submit_feedback` escreve diretamente no arquivo CSV local e ativa a flag.
# - A função `save_data_on_exit`, registrada com `atexit`, faz o commit para o Hugging Face
# apenas no desligamento do Space, e somente se a flag estiver ativa.
#
# 2. SEM MUDANÇAS NO RESTO DO CÓDIGO:
# - A lógica de busca, carregamento de modelos e outros endpoints permanecem inalterados,
# usando a arquitetura robusta que definimos.
#
###################################################################################################
import pandas as pd
from flask import Flask, render_template, request, jsonify
import os
import sys
import traceback
from sentence_transformers import CrossEncoder
import csv
from collections import defaultdict
import datetime
import re
from huggingface_hub import InferenceClient, HfApi
from huggingface_hub.utils import HfHubHTTPError
import atexit
import json
from hashlib import sha1
# --- Bloco 1: Configuração da Aplicação e Variáveis Globais ---
# Configuração de Feedback e Persistência
USER_FEEDBACK_FILE = 'user_feedback.csv'
USER_BEST_MATCHES_COUNTS = {}
USER_FEEDBACK_THRESHOLD = 3
FEEDBACK_CSV_COLUMNS = ['timestamp', 'query_original', 'query_normalized', 'tuss_code_submitted', 'tuss_code_raw_input', 'tuss_description_associated', 'rol_names_associated', 'feedback_type']
DATA_HAS_CHANGED = False # Flag para rastrear se precisamos salvar na saída
# Configuração do Cliente de IA Generativa
api_key = os.environ.get("USUARIO_KEY")
if not api_key:
print("--- [AVISO CRÍTICO] Secret 'USUARIO_KEY' não encontrado. As chamadas para a IA irão falhar. ---")
client_ia = None
else:
client_ia = InferenceClient(provider="novita", api_key=api_key)
print("--- [SUCESSO] Cliente de Inferência da IA configurado.")
# Configuração do Repositório Hugging Face
HF_TOKEN = os.environ.get("HF_TOKEN")
REPO_ID = "tuliodisanto/Buscador_Rol_vs.4_IA"
if not HF_TOKEN:
print("--- [AVISO CRÍTICO] Secret 'HF_TOKEN' não encontrado. Os arquivos não serão salvos no repositório. ---")
hf_api = None
else:
hf_api = HfApi(token=HF_TOKEN)
print(f"--- [SUCESSO] Cliente da API do Hugging Face configurado para o repositório: {REPO_ID}. ---")
# --- Bloco 2: Funções de Feedback e Persistência ---
def normalize_text_for_feedback(text):
"""Função de normalização dedicada ao feedback para evitar dependências circulares."""
if pd.isna(text): return ""
import unidecode
normalized = unidecode.unidecode(str(text).lower())
normalized = re.sub(r'[^\w\s]', ' ', normalized)
return re.sub(r'\s+', ' ', normalized).strip()
def load_user_feedback():
"""Carrega o histórico de feedbacks do CSV para a memória."""
global USER_BEST_MATCHES_COUNTS
USER_BEST_MATCHES_COUNTS = defaultdict(lambda: defaultdict(int))
feedback_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), USER_FEEDBACK_FILE)
if not os.path.exists(feedback_file_path):
with open(feedback_file_path, 'w', newline='', encoding='utf-8') as f: csv.writer(f).writerow(FEEDBACK_CSV_COLUMNS)
return
try:
with open(feedback_file_path, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
query_norm, tuss_code = row.get('query_normalized', ''), row.get('tuss_code_submitted', '')
if query_norm and tuss_code:
USER_BEST_MATCHES_COUNTS[query_norm][tuss_code] += 1
print(f"--- [SUCESSO] Feedback de usuário carregado. {len(USER_BEST_MATCHES_COUNTS)} queries com feedback.")
except Exception as e: print(f"--- [ERRO] Falha ao carregar feedback: {e} ---"); traceback.print_exc()
def commit_file_to_repo(local_file_name, commit_message):
"""Faz o upload de um arquivo para o repositório no Hugging Face Hub."""
if not hf_api:
print(f"--- [AVISO] API do HF não configurada. Pular o commit de '{local_file_name}'. ---")
return
local_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), local_file_name)
if not os.path.exists(local_file_path) or os.path.getsize(local_file_path) == 0:
print(f"--- [AVISO] Arquivo '{local_file_name}' não existe ou está vazio. Pular commit. ---")
return
try:
print(f"--- [API HF] Tentando fazer o commit de '{local_file_name}' para o repositório... ---")
hf_api.upload_file(path_or_fileobj=local_file_path, path_in_repo=local_file_name, repo_id=REPO_ID, repo_type="space", commit_message=commit_message)
print(f"--- [API HF] Sucesso no commit de '{local_file_name}'. ---")
except Exception as e:
print(f"--- [ERRO API HF] Falha no commit de '{local_file_name}': {e} ---")
def save_data_on_exit():
"""Função registrada para ser executada no desligamento da aplicação, salvando dados se necessário."""
print("--- [SHUTDOWN] Verificando dados para salvar... ---")
if DATA_HAS_CHANGED:
print(f"--- [SHUTDOWN] Mudanças detectadas. Fazendo o commit de '{USER_FEEDBACK_FILE}' para o repositório. ---")
commit_file_to_repo(USER_FEEDBACK_FILE, "Commit automático: Atualiza feedbacks de usuários.")
else:
print("--- [SHUTDOWN] Nenhuma mudança nos dados detectada. Nenhum commit necessário. ---")
atexit.register(save_data_on_exit)
# --- Bloco 3: Inicialização da Aplicação e Carregamento de Dados ---
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
from enhanced_search_v2 import load_and_prepare_database, load_correction_corpus, load_general_dictionary, search_procedure_with_log
print("--- [SUCESSO] Módulo 'enhanced_search_v2.py' importado. ---")
except Exception as e:
print(f"--- [ERRO CRÍTICO] Não foi possível importar 'enhanced_search_v2.py': {e} ---"); traceback.print_exc(); sys.exit(1)
app = Flask(__name__)
# Declaração das variáveis globais
DF_ORIGINAL, DF_NORMALIZED, FUZZY_CORPUS, BM25_MODEL, DOC_FREQ = (None, None, None, None, {})
CORRECTION_CORPUS = ([], [])
VALID_WORDS_SET = set()
CROSS_ENCODER_MODEL = None
try:
db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'rol_procedures_database.csv')
DF_ORIGINAL, DF_NORMALIZED, FUZZY_CORPUS, BM25_MODEL, DOC_FREQ = load_and_prepare_database(db_path)
dict_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Dic.csv')
original_terms, normalized_terms, db_word_set = load_correction_corpus(dict_path, column_name='Termo_Correto')
CORRECTION_CORPUS = (original_terms, normalized_terms)
general_dict_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dicionario_ptbr.txt')
portuguese_word_set = load_general_dictionary(general_dict_path)
VALID_WORDS_SET = db_word_set.union(portuguese_word_set)
print(f"--- [SUCESSO] Dicionário unificado criado com {len(VALID_WORDS_SET)} palavras válidas. ---")
load_user_feedback()
print("\n--- [SETUP] Carregando modelo Cross-Encoder... ---")
cross_encoder_model_name = 'cross-encoder/ms-marco-MiniLM-L-6-v2'
CROSS_ENCODER_MODEL = CrossEncoder(cross_encoder_model_name, device='cpu')
print(f"--- [SUCESSO] Modelo Cross-Encoder '{cross_encoder_model_name}' carregado. ---")
except Exception as e:
print(f"--- [ERRO CRÍTICO] Falha fatal durante o setup: {e} ---"); traceback.print_exc(); sys.exit(1)
# --- Bloco 4: Definição dos Endpoints da API ---
@app.route('/')
def index():
return render_template('index.html')
@app.route('/favicon.ico')
def favicon():
return '', 204
@app.route('/search', methods=['POST'])
def search():
"""Endpoint principal que recebe a query e retorna os resultados da busca."""
try:
data = request.get_json()
query = data.get('query', '').strip()
results = search_procedure_with_log(
query=query,
df_original=DF_ORIGINAL,
df_normalized=DF_NORMALIZED,
fuzzy_search_corpus=FUZZY_CORPUS,
correction_corpus=CORRECTION_CORPUS,
valid_words_set=VALID_WORDS_SET,
bm25_model=BM25_MODEL,
doc_freq=DOC_FREQ,
cross_encoder_model=CROSS_ENCODER_MODEL,
user_best_matches_counts=USER_BEST_MATCHES_COUNTS,
user_feedback_threshold=USER_FEEDBACK_THRESHOLD
)
return jsonify(results)
except Exception as e:
print("--- [ERRO FATAL DURANTE A BUSCA] ---"); traceback.print_exc()
return jsonify({"error": "Ocorreu um erro interno no motor de busca."}), 500
@app.route('/submit_feedback', methods=['POST'])
def submit_feedback_route():
"""Endpoint para receber e registrar o feedback dos usuários, com lógica de salvamento robusta."""
global DATA_HAS_CHANGED
try:
data = request.get_json()
query, tuss_code_submitted = data.get('query'), data.get('tuss_code')
if not query or not tuss_code_submitted: return jsonify({"status": "error", "message": "Dados incompletos."}), 400
file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), USER_FEEDBACK_FILE)
# Abre o arquivo em modo de adição ('a') para não apagar o conteúdo existente
with open(file_path, 'a', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
# Lógica simples para garantir que o cabeçalho exista (não ideal para concorrência, mas ok aqui)
f.seek(0, 2) # Vai para o fim do arquivo
if f.tell() == 0: # Se o ponteiro está no início, o arquivo está vazio
writer.writerow(FEEDBACK_CSV_COLUMNS)
# Constrói e escreve a nova linha de feedback
query_normalized = normalize_text_for_feedback(query)
matching_rows = DF_ORIGINAL[DF_ORIGINAL['Codigo_TUSS'].astype(str) == tuss_code_submitted]
tuss_desc_assoc = " | ".join(matching_rows['Descricao_TUSS'].unique()) if not matching_rows.empty else 'Não encontrado'
rol_names_assoc = " | ".join(matching_rows['Procedimento_Rol'].unique()) if not matching_rows.empty else 'Não encontrado'
writer.writerow([datetime.datetime.now().isoformat(), query, query_normalized, tuss_code_submitted, '', tuss_desc_assoc, rol_names_assoc, 'confirm_result'])
# Ativa a flag para indicar que o arquivo foi modificado e precisa ser salvo no desligamento
DATA_HAS_CHANGED = True
print(f"--- [DADOS] Feedback recebido para a query '{query}'. Commit agendado para o desligamento. ---")
# Recarrega o feedback na memória para que a próxima busca já o considere
load_user_feedback()
return jsonify({"status": "success", "message": "Feedback recebido!"}), 200
except Exception as e:
print("--- [ERRO NO SUBMIT_FEEDBACK] ---"); traceback.print_exc();
return jsonify({"status": "error", "message": "Erro interno."}), 500
@app.route('/get_tuss_info', methods=['GET'])
def get_tuss_info():
"""Endpoint para autocompletar códigos TUSS na interface."""
tuss_code_prefix = request.args.get('tuss_prefix', '').strip()
if not tuss_code_prefix: return jsonify([])
suggestions = []
if DF_ORIGINAL is not None:
filtered_df = DF_ORIGINAL[DF_ORIGINAL['Codigo_TUSS'].astype(str).str.startswith(tuss_code_prefix)]
tuss_grouped = filtered_df.groupby('Codigo_TUSS').agg(tuss_descriptions=('Descricao_TUSS', 'unique'), rol_names=('Procedimento_Rol', 'unique')).reset_index()
for _, row in tuss_grouped.head(10).iterrows():
suggestions.append({'tuss_code': str(row['Codigo_TUSS']), 'tuss_description': " | ".join(row['tuss_descriptions']), 'rol_name': " | ".join(row['rol_names'])})
return jsonify(suggestions)
@app.route('/get_ai_suggestion', methods=['POST'])
def get_ai_suggestion():
"""Endpoint para obter sugestões de uma IA Generativa baseada nos resultados da busca."""
if not client_ia: return jsonify({"error": "O serviço de IA não está configurado."}), 503
try:
data = request.get_json()
query, results = data.get('query'), data.get('results', [])
if not query or not results: return jsonify({"error": "A consulta e os resultados são necessários."}), 400
RELEVANT_KEYS_FOR_AI = [ 'Codigo_TUSS', 'Descricao_TUSS', 'Procedimento_Rol', 'CAPITULO', 'GRUPO', 'SUBGRUPO', 'Semantico', 'Sinonimo_1', 'Sinonimo_2' ]
simplified_results = []
for r in results:
unique_id = f"{r.get('Codigo_TUSS')}_{sha1(str(r.get('Procedimento_Rol', '')).encode('utf-8')).hexdigest()[:8]}"
pruned_result = {'unique_id': unique_id, **{key: r.get(key) for key in RELEVANT_KEYS_FOR_AI if r.get(key) and pd.notna(r.get(key))}}
if 'Codigo_TUSS' in pruned_result: simplified_results.append(pruned_result)
formatted_results_str = json.dumps(simplified_results, indent=2, ensure_ascii=False)
system_prompt = ( "Você é um especialista em terminologia de procedimentos médicos do Brasil (Tabela TUSS e Rol da ANS). " "Sua tarefa é analisar uma lista de procedimentos e escolher os 3 que melhor correspondem à consulta do usuário, em ordem de relevância." )
user_prompt = f"""Consulta do usuário: "{query}"
### Resultados da Busca para Análise (JSON):
{formatted_results_str}
### Sua Tarefa:
1. **Pense em voz alta:** Dentro de uma tag `<thought>`, explique seu processo de raciocínio passo a passo.
2. **Forneça a resposta final:** Após a tag `<thought>`, seu único resultado deve ser um bloco de código JSON contendo uma chave `suggested_ids` com uma lista de **EXATAMENTE 3 strings** do campo `unique_id` que você selecionou, ordenadas da mais para a menos relevante."""
completion = client_ia.chat.completions.create( model="baidu/ERNIE-4.5-21B-A3B-PT", messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}], max_tokens=1500, temperature=0.1 )
raw_response = completion.choices[0].message.content.strip()
thought_process = "Não foi possível extrair o raciocínio da resposta da IA."
json_part = None
if "<thought>" in raw_response and "</thought>" in raw_response:
start = raw_response.find("<thought>") + len("<thought>")
end = raw_response.find("</thought>")
thought_process = raw_response[start:end].strip()
if "```json" in raw_response:
start = raw_response.find("```json") + len("```json")
end = raw_response.rfind("```")
json_str = raw_response[start:end].strip()
try: json_part = json.loads(json_str)
except json.JSONDecodeError: pass
if not json_part or "suggested_ids" not in json_part or not isinstance(json_part.get("suggested_ids"), list):
return jsonify({ "error": "A IA não retornou a lista de 'suggested_ids' no formato esperado.", "details": raw_response }), 422
return jsonify({ "suggested_ids": json_part["suggested_ids"][:3], "thought_process": thought_process })
except Exception as e:
print("--- [ERRO FATAL NA SUGESTÃO DA IA] ---"); traceback.print_exc()
return jsonify({"error": f"Ocorreu um erro interno na IA: {str(e)}"}), 500
# --- Bloco 5: Execução da Aplicação ---
if __name__ == '__main__':
port = int(os.environ.get("PORT", 7860))
app.run(host='0.0.0.0', port=port, debug=False)