Spaces:
Sleeping
Sleeping
| # 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 --- | |
| def index(): | |
| return render_template('index.html') | |
| def favicon(): | |
| return '', 204 | |
| 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 | |
| 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 | |
| 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) | |
| 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) |