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)