Spaces:
				
			
			
	
			
			
		Sleeping
		
	
	
	
			
			
	
	
	
	
		
		
		Sleeping
		
	File size: 16,701 Bytes
			
			f3ebbc7  | 
								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 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342  | 
								# app.py (Versão Híbrida Final com Bi-Encoder e Cross-Encoder)
import pandas as pd
from flask import Flask, render_template, request, jsonify
import os
import sys
import traceback
import subprocess
# Importa as duas classes de modelos necessárias
from sentence_transformers import SentenceTransformer, 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
# --- Variáveis e Constantes de Feedback ---
USER_FEEDBACK_FILE = 'user_feedback.csv'
USER_BEST_MATCHES_COUNTS = {}
USER_FEEDBACK_THRESHOLD = 10
FEEDBACK_CSV_COLUMNS = ['timestamp', 'query_original', 'query_normalized', 'tuss_code_submitted', 'tuss_code_raw_input', 'tuss_description_associated', 'rol_names_associated', 'feedback_type']
# --- Configuração do Cliente da IA ---
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 com a chave correta. ---")
# --- SEÇÃO DE PERSISTÊNCIA ---
HF_TOKEN = os.environ.get("HF_TOKEN") 
REPO_ID = "tuliodisanto/Buscador_Rol_vs.2_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}. ---")
DATA_HAS_CHANGED = False
# --- Funções de Feedback ---
def normalize_text_for_feedback(text):
    if pd.isna(text): return ""
    try:
        from enhanced_search_v2 import normalize_text as es_normalize_text
        return es_normalize_text(str(text).strip())
    except ImportError:
        import unidecode
        return unidecode.unidecode(str(text).lower().strip())
def load_user_feedback():
    global USER_BEST_MATCHES_COUNTS
    USER_BEST_MATCHES_COUNTS = {}
    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.reader(f)
            try:
                header = next(reader)
                if [col.strip() for col in header] != FEEDBACK_CSV_COLUMNS:
                    print(f"--- [AVISO] Cabeçalho do {USER_FEEDBACK_FILE} incorreto.")
                    return
            except StopIteration:
                return
            for row in reader:
                if len(row) == len(FEEDBACK_CSV_COLUMNS):
                    row_dict = dict(zip(FEEDBACK_CSV_COLUMNS, row))
                    query_norm, tuss_code = row_dict.get('query_normalized', ''), row_dict.get('tuss_code_submitted', '')
                    if query_norm and tuss_code:
                        if query_norm not in USER_BEST_MATCHES_COUNTS: USER_BEST_MATCHES_COUNTS[query_norm] = {}
                        USER_BEST_MATCHES_COUNTS[query_norm][tuss_code] = USER_BEST_MATCHES_COUNTS[query_norm].get(tuss_code, 0) + 1
        print(f"--- [SUCESSO] Feedback de usuário carregado/sincronizado. ---")
    except Exception as e: print(f"--- [ERRO] Falha ao carregar feedback: {e} ---"); traceback.print_exc()
# --- Execução de Scripts e Importações ---
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__)
# --- Carregamento dos Dados e Modelos ---
# CORREÇÃO 1: Preparar as variáveis para receber todos os 7 valores de load_and_prepare_database
DF_ORIGINAL, DF_NORMALIZED, FUZZY_CORPUS, BM25_MODEL, DB_WORD_SET, doc_freq, tuss_map = (None, None, None, None, set(), {}, {})
CORRECTION_CORPUS, NORMALIZED_CORRECTION_CORPUS = [], []
PORTUGUESE_WORD_SET = set()
SEMANTIC_MODEL = None      # Bi-Encoder
CROSS_ENCODER_MODEL = None # Cross-Encoder
try:
    # Carregamento dos dados
    db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'rol_procedures_database.csv')
    # CORREÇÃO 1 (continuação): Desempacotar os 7 valores retornados pela função
    DF_ORIGINAL, DF_NORMALIZED, FUZZY_CORPUS, BM25_MODEL, DB_WORD_SET, doc_freq, tuss_map = load_and_prepare_database(db_path)
    
    dict_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Dic.csv')
    CORRECTION_CORPUS, NORMALIZED_CORRECTION_CORPUS = load_correction_corpus(dict_path, column_name='Termo_Correto')
    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)
    load_user_feedback()
    # Carregamento dos dois modelos semânticos
    print("\n--- [SETUP] Carregando modelo Bi-Encoder (Etapa 1 de reordenação)... ---")
    bi_encoder_model_name = 'sentence-transformers/all-MiniLM-L6-v2'
    SEMANTIC_MODEL = SentenceTransformer(bi_encoder_model_name, device='cpu')
    print(f"--- [SUCESSO] Modelo Bi-Encoder '{bi_encoder_model_name}' carregado. ---")
    print("\n--- [SETUP] Carregando modelo Cross-Encoder (Etapa 2 de reordenação)... ---")
    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)
# --- Rotas da Aplicação ---
@app.route('/')
def index(): return render_template('index.html')
@app.route('/favicon.ico')
def favicon(): return '', 204
@app.route('/search', methods=['POST'])
def search():
    try:
        data = request.get_json()
        query = data.get('query', '').strip()
        
        # CORREÇÃO 2: Reordenar os argumentos da função. Todos os posicionais primeiro, depois os de palavra-chave.
        results = search_procedure_with_log(
            query, 
            DF_ORIGINAL, 
            DF_NORMALIZED, 
            FUZZY_CORPUS,
            (CORRECTION_CORPUS, NORMALIZED_CORRECTION_CORPUS), 
            PORTUGUESE_WORD_SET,
            BM25_MODEL,
            DB_WORD_SET, 
            doc_freq,          # <-- Novo argumento posicional
            tuss_map,          # <-- Novo argumento posicional
            limit_per_layer=10, 
            semantic_model=SEMANTIC_MODEL,
            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():
    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)
        with open(file_path, 'a', newline='', encoding='utf-8') as f:
            writer = csv.writer(f)
            query_normalized = normalize_text_for_feedback(query)
            
            tuss_descriptions, rol_names = [], []
            if DF_ORIGINAL is not None:
                matching_rows = DF_ORIGINAL[DF_ORIGINAL['Codigo_TUSS'].astype(str) == tuss_code_submitted]
                if not matching_rows.empty:
                    tuss_descriptions = matching_rows['Descricao_TUSS'].unique().tolist()
                    rol_names = matching_rows['Procedimento_Rol'].unique().tolist()
            tuss_desc_assoc = " | ".join(filter(None, tuss_descriptions)) or 'Não encontrado'
            rol_names_assoc = " | ".join(filter(None, rol_names)) or 'Não encontrado'
            
            writer.writerow([datetime.datetime.now().isoformat(), query, query_normalized, tuss_code_submitted, '', tuss_desc_assoc, rol_names_assoc, 'confirm_result'])
        
        DATA_HAS_CHANGED = True
        print(f"--- [DADOS] '{USER_FEEDBACK_FILE}' foi modificado. Commit agendado para o desligamento. ---")
        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():
    tuss_code_prefix = request.args.get('tuss_prefix', '').strip()
    if not tuss_code_prefix: return jsonify([])
    suggestions = []
    if DF_ORIGINAL is not None and not DF_ORIGINAL.empty:
        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', lambda x: list(x.unique())), rol_names=('Procedimento_Rol', lambda x: list(x.unique()))).reset_index()
        for index, row in tuss_grouped.head(10).iterrows():
            tuss_desc = " | ".join(filter(None, row['tuss_descriptions'])) or 'Sem descrição TUSS'
            rol_name = " | ".join(filter(None, row['rol_names'])) or 'Sem procedimento Rol'
            suggestions.append({'tuss_code': str(row['Codigo_TUSS']), 'tuss_description': tuss_desc, 'rol_name': rol_name})
    return jsonify(suggestions)
@app.route('/get_ai_suggestion', methods=['POST'])
def get_ai_suggestion():
    if not client_ia:
        return jsonify({"error": "O serviço de IA não está configurado no servidor."}), 503
    try:
        data = request.get_json()
        query = data.get('query')
        results = 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:
            procedimento_rol_str = str(r.get('Procedimento_Rol', ''))
            unique_id = f"{r.get('Codigo_TUSS')}_{sha1(procedimento_rol_str.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)) and str(r.get(key)).strip() not in ['---', '']}
            }
            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}\"\n\n"
            f"### Resultados da Busca para Análise (JSON):\n{formatted_results_str}\n\n"
            "### Sua Tarefa:\n"
            "1.  **Pense em voz alta:** Dentro de uma tag `<thought>`, explique seu processo de raciocínio passo a passo. Analise a consulta e compare os resultados, justificando por que um é mais relevante que o outro com base em seu `Procedimento_Rol` e outros campos.\n"
            "2.  **Forneça a resposta final:** Após a tag `<thought>`, seu único resultado deve ser um bloco de código JSON. Este JSON **DEVE** conter 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.\n\n"
            "**EXEMPLO DE RESPOSTA OBRIGATÓRIA:**\n"
            "<thought>\n"
            "O Raciocínio da IA fica aqui...\n"
            "</thought>\n"
            "```json\n"
            "{\n"
            '  "suggested_ids": ["30602122_abc12345", "30602360_def67890", "30602033_ghi11223"]\n'
            "}\n"
            "```"
        )
        
        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) or len(json_part["suggested_ids"]) == 0:
            return jsonify({
                "error": "A IA não retornou a lista de 'suggested_ids' no formato JSON esperado.",
                "details": raw_response
            }), 422
        
        response_data = {
            "suggested_ids": json_part["suggested_ids"][:3],
            "thought_process": thought_process,
            "query": query,
            "results": simplified_results
        }
        return jsonify(response_data)
    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
# --- SEÇÃO DE PERSISTÊNCIA ---
def commit_file_to_repo(local_file_name, commit_message):
    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
    path_in_repo = local_file_name
    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=path_in_repo,
            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():
    print("--- [SHUTDOWN] O aplicativo está desligando. Verificando dados para salvar... ---")
    if DATA_HAS_CHANGED:
        print("--- [SHUTDOWN] Mudanças detectadas. Fazendo o commit dos arquivos 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)
if __name__ == '__main__':
    port = int(os.environ.get("PORT", 7860))
    app.run(host='0.0.0.0', port=port, debug=False) |