Spaces:
Sleeping
Sleeping
File size: 15,995 Bytes
f2dfda4 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 |
# 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) |