leandroaraujodev commited on
Commit
d368963
·
1 Parent(s): 1288cd7

teste funcionalidade de autenticação

Browse files
.streamlit/config.toml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ [theme]
2
+
3
+ base = 'light'
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11
2
+
3
+ # Criação do diretório de trabalho
4
+ WORKDIR /app
5
+
6
+ # Instalação das dependências
7
+ COPY requirements.txt .
8
+ RUN pip install --no-cache-dir -r requirements.txt
9
+
10
+ # Criação de um novo usuário com UID 1000
11
+ RUN useradd -m -u 1000 user
12
+
13
+ # Mudança para o novo usuário
14
+ USER user
15
+
16
+ # Definição das variáveis de ambiente
17
+ ENV HOME=/home/user \
18
+ PATH=/home/user/.local/bin:$PATH
19
+
20
+ # Definição do diretório de trabalho para o novo usuário
21
+ WORKDIR $HOME/app
22
+
23
+ # Cópia do código da aplicação com propriedade atribuída ao novo usuário
24
+ COPY --chown=user . $HOME/app
25
+
26
+ # Exposição da porta para o Streamlit
27
+ EXPOSE 7860
28
+
29
+ # Comando para iniciar o Flask em segundo plano e o Streamlit
30
+ CMD ["sh", "-c", "python chatbot_server.py & streamlit run app.py --server.port 7860 --server.address 0.0.0.0"]
app.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import requests
3
+ from PIL import Image
4
+ import base64
5
+
6
+ import streamlit as st
7
+ import streamlit_authenticator as stauth
8
+
9
+ import yaml
10
+ from yaml.loader import SafeLoader
11
+
12
+
13
+ class ChatbotApp:
14
+ def __init__(self):
15
+ # URL do backend (Flask)
16
+ self.backend_url = "http://localhost:5001/chat"
17
+ self.title = "Chatbot Carômetro"
18
+ self.description = "Este assistente virtual pode te ajudar com informações sobre carômetros da Sicoob."
19
+
20
+ def stream_chat(self, user_input):
21
+ """
22
+ Faz a comunicação com o backend e retorna a resposta como streaming de tokens.
23
+ """
24
+ try:
25
+ response = requests.post(
26
+ self.backend_url,
27
+ json={"message": user_input},
28
+ stream=True # Ativa o streaming
29
+ )
30
+ response.raise_for_status()
31
+
32
+ # Gera os tokens conforme chegam no streaming
33
+ for chunk in response.iter_content(chunk_size=512):
34
+ if chunk:
35
+ yield chunk.decode("utf-8")
36
+ except Exception as e:
37
+ yield f"Erro ao conectar ao servidor: {e}"
38
+
39
+ def render_sidebar(self):
40
+ """
41
+ Exibe opções na barra lateral e renderiza a logo do Sicoob.
42
+ """
43
+ st.sidebar.title("Configuração de LLM")
44
+ sidebar_option = st.sidebar.radio("Selecione o LLM", ["gpt-3.5-turbo"])
45
+ if sidebar_option != "gpt-3.5-turbo":
46
+ raise Exception("Opção de LLM inválida!")
47
+
48
+ # Exibe a logo do Sicoob na barra lateral
49
+ with open("sicoob-logo.png", "rb") as f:
50
+ data = base64.b64encode(f.read()).decode("utf-8")
51
+ st.sidebar.markdown(
52
+ f"""
53
+ <div style="display:table;margin-top:-80%;margin-left:0%;">
54
+ <img src="data:image/png;base64,{data}" width="250" height="70">
55
+ </div>
56
+ """,
57
+ unsafe_allow_html=True,
58
+ )
59
+
60
+ def render(self):
61
+ """
62
+ Renderiza a interface do chatbot.
63
+ """
64
+ # Configura título, ícone e layout da página
65
+ im = Image.open("pngegg.png")
66
+ st.set_page_config(page_title="Chatbot Carômetro", page_icon=im, layout="wide")
67
+
68
+ with open('./config.yaml') as file:
69
+ config = yaml.load(file, Loader=SafeLoader)
70
+
71
+ # Pre-hashing all plain text passwords once
72
+ # stauth.Hasher.hash_passwords(config['credentials'])
73
+
74
+ authenticator = stauth.Authenticate(
75
+ config['credentials'],
76
+ config['cookie']['name'],
77
+ config['cookie']['key'],
78
+ config['cookie']['expiry_days']
79
+ )
80
+
81
+ #try:
82
+ # authenticator.login()
83
+ #except Exception as e:
84
+ # st.error(e)
85
+
86
+ with open('./config.yaml', 'w', encoding='utf-8') as file:
87
+ yaml.dump(config, file, default_flow_style=False)
88
+
89
+
90
+ authentication_status = authenticator.login()
91
+
92
+ if st.session_state["authentication_status"]:
93
+ authenticator.logout('Logout', 'main')
94
+
95
+ # Renderiza a barra lateral
96
+ self.render_sidebar()
97
+
98
+ # Título e descrição
99
+ st.title(self.title)
100
+ st.write(self.description)
101
+
102
+ # Inicializa o histórico na sessão
103
+ if "chat_history" not in st.session_state:
104
+ st.session_state.chat_history = []
105
+
106
+ # Renderiza as mensagens do histórico
107
+ for message in st.session_state.chat_history:
108
+ role, text = message.split(":", 1)
109
+ with st.chat_message(role.strip().lower()):
110
+ st.write(text.strip())
111
+
112
+ # Captura o input do usuário
113
+ user_input = st.chat_input("Digite sua pergunta")
114
+ if user_input:
115
+ # Exibe a mensagem do usuário
116
+ with st.chat_message("user"):
117
+ st.write(user_input)
118
+ st.session_state.chat_history.append(f"user: {user_input}")
119
+
120
+ # Placeholder para a resposta do assistente
121
+ with st.chat_message("assistant"):
122
+ message_placeholder = st.empty()
123
+ assistant_message = ""
124
+
125
+ # Executa o streaming de tokens enquanto o backend responde
126
+ for token in self.stream_chat(user_input):
127
+ assistant_message += token
128
+ message_placeholder.markdown(assistant_message + "▌")
129
+
130
+ # Atualiza o placeholder com a mensagem final
131
+ message_placeholder.markdown(assistant_message)
132
+ st.session_state.chat_history.append(f"assistant: {assistant_message}")
133
+ elif st.session_state["authentication_status"] == False:
134
+ st.error('Username/password is incorrect')
135
+ elif st.session_state["authentication_status"] == None:
136
+ st.warning('Please enter your username and password')
137
+
138
+ if __name__ == "__main__":
139
+ chatbot_app = ChatbotApp()
140
+ chatbot_app.render()
chatbot_server.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ import sys
4
+
5
+ from flask import Flask, request, jsonify, Response
6
+ # Inicializa o Flask
7
+ app = Flask(__name__)
8
+
9
+ logging.basicConfig(stream=sys.stdout, level=logging.INFO)
10
+
11
+ from llama_index.llms.openai import OpenAI
12
+ from llama_index.embeddings.openai import OpenAIEmbedding
13
+ from llama_index.core import (
14
+ Settings,
15
+ SimpleDirectoryReader,
16
+ StorageContext,
17
+ Document,
18
+ )
19
+
20
+ Settings.llm = OpenAI(model="gpt-3.5-turbo")
21
+ Settings.embed_model = OpenAIEmbedding(model_name="text-embedding-3-small")
22
+ directory_path = "documentos"
23
+ from llama_index.readers.file import PDFReader #concatenar todo o documento já vem nativo no pdfreader
24
+ file_extractor = {".pdf": PDFReader(return_full_document = True)}
25
+ from drive_downloader import GoogleDriveDownloader
26
+
27
+ # ID da pasta no Drive e caminho local
28
+ folder_id = "1n34bmh9rlbOtCvE_WPZRukQilKeabWsN"
29
+ local_path = directory_path
30
+
31
+ GoogleDriveDownloader().download_from_folder(folder_id, local_path)
32
+
33
+ documents = SimpleDirectoryReader(
34
+ input_dir=directory_path,
35
+ file_extractor=file_extractor,
36
+ filename_as_id=True,
37
+ recursive=True
38
+ ).load_data()
39
+
40
+ from document_creator import create_single_document_with_filenames
41
+ document = create_single_document_with_filenames(directory_path = directory_path)
42
+ documents.append(document)
43
+
44
+ #from llama_index.core.ingestion import IngestionPipeline
45
+ #ingestion pipeline vai entrar em uso quando adicionar o extrator de metadados
46
+ from llama_index.core.node_parser import SentenceSplitter
47
+ splitter = SentenceSplitter(chunk_size=1024, chunk_overlap=128)
48
+ nodes = splitter.get_nodes_from_documents(documents)
49
+
50
+ from llama_index.core.storage.docstore import SimpleDocumentStore
51
+ docstore = SimpleDocumentStore()
52
+ docstore.add_documents(nodes)
53
+
54
+ from llama_index.core import VectorStoreIndex, StorageContext
55
+ from llama_index.vector_stores.chroma import ChromaVectorStore
56
+ import chromadb
57
+
58
+ db = chromadb.PersistentClient(path="chroma_db")
59
+ chroma_collection = db.get_or_create_collection("dense_vectors")
60
+ vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
61
+ storage_context = StorageContext.from_defaults(
62
+ docstore=docstore, vector_store=vector_store
63
+ )
64
+ index = VectorStoreIndex(nodes = nodes, storage_context=storage_context, show_progress = True)
65
+
66
+ storage_context.docstore.persist("./docstore.json")
67
+
68
+ index_retriever = index.as_retriever(similarity_top_k=2)
69
+ import nest_asyncio
70
+ nest_asyncio.apply()
71
+ from llama_index.retrievers.bm25 import BM25Retriever
72
+ bm25_retriever = BM25Retriever.from_defaults(
73
+ docstore=index.docstore,
74
+ similarity_top_k=2,
75
+ language = "portuguese",
76
+ verbose=True,
77
+ )
78
+
79
+ from llama_index.core.retrievers import QueryFusionRetriever
80
+
81
+ retriever = QueryFusionRetriever(
82
+ [index_retriever, bm25_retriever],
83
+ num_queries=1, #desativado = 1
84
+ mode="reciprocal_rerank",
85
+ use_async=True,
86
+ verbose=True,
87
+ )
88
+
89
+ from llama_index.core.storage.chat_store import SimpleChatStore
90
+ from llama_index.core.memory import ChatMemoryBuffer
91
+ chat_store = SimpleChatStore()
92
+ chat_memory = ChatMemoryBuffer.from_defaults(
93
+ token_limit=3000,
94
+ chat_store=chat_store,
95
+ chat_store_key="user1",
96
+ )
97
+ from llama_index.core.query_engine import RetrieverQueryEngine
98
+ query_engine = RetrieverQueryEngine.from_args(retriever)
99
+ from llama_index.core.chat_engine import CondensePlusContextChatEngine
100
+ chat_engine = CondensePlusContextChatEngine.from_defaults(
101
+ query_engine,
102
+ memory=chat_memory,
103
+ context_prompt=(
104
+ "Você é um assistente virtual capaz de interagir normalmente, além de"
105
+ " fornecer informações sobre organogramas e listar funcionários."
106
+ " Aqui estão os documentos relevantes para o contexto:\n"
107
+ "{context_str}"
108
+ "\nInstrução: Use o histórico da conversa anterior, ou o contexto acima, para responder."
109
+ ),
110
+ )
111
+
112
+
113
+
114
+ @app.route("/chat", methods=["POST"])
115
+ def chat():
116
+ user_input = request.json.get("message", "")
117
+ if not user_input:
118
+ return jsonify({"error": "Mensagem vazia"}), 400
119
+
120
+ def generate_response():
121
+ try:
122
+ response = chat_engine.stream_chat(user_input)
123
+ for token in response.response_gen:
124
+ yield token # Envia cada token
125
+ chat_store.persist(persist_path="chat_store.json")
126
+ except Exception as e:
127
+ yield f"Erro: {str(e)}"
128
+
129
+ return Response(generate_response(), content_type="text/plain")
130
+ if __name__ == "__main__":
131
+ app.run(port=5001, debug=False)
config.yaml ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ cookie:
2
+ expiry_days: 30
3
+ key: some_signature_key
4
+ name: some_cookie_name
5
+ credentials:
6
+ usernames:
7
+ akcit_root:
8
9
+ failed_login_attempts: 0
10
+ first_name: akcit
11
+ last_name: root
12
+ logged_in: false
13
+ password: $2b$12$wd1lg1DTs4qEDRmohdptDegeSJhGstxqgCaTitfRQ0IGN.rnr51aG
14
+ roles:
15
+ - admin
16
+ - editor
17
+ - viewer
18
+ sicoob_central:
19
20
+ failed_login_attempts: 0
21
+ first_name: sicoob
22
+ last_name: central
23
+ logged_in: false
24
+ password: $2b$12$Y5tHfGABzVP9dm510HuHHuUbeZvNqUibUgj4TYH40rglhZGLPZ8rK
25
+ roles:
26
+ - viewer
27
+ sicoob_unidade:
28
29
+ failed_login_attempts: 0
30
+ first_name: sicoob
31
+ last_name: unidade
32
+ logged_in: false
33
+ password: $2b$12$h8U7XrVfACkHJaqGqcwR0OzDO.YorKF21lHpG/9MVa4K/98AbXtG.
34
+ roles:
35
+ - viewer
36
+ pre-authorized:
37
+ emails:
38
document_creator.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ from pathlib import Path
4
+ from llama_index.core import Document
5
+
6
+ def create_single_document_with_filenames(directory_path: str) -> Document:
7
+ """
8
+ Percorre a pasta informada, organiza os arquivos em estrutura de anos (YYYY) e meses (MM),
9
+ gera um texto descritivo semelhante ao código anterior (sem salvar em arquivo) e retorna
10
+ esse texto dentro de um objeto Document.
11
+
12
+ Uso:
13
+ directory_path = "documentos"
14
+ doc = create_single_document_with_filenames(directory_path)
15
+ documents.append(doc)
16
+ """
17
+
18
+ # Dicionário {ano: {mes: [arquivos]}}
19
+ estrutura_anos = {}
20
+ # Lista para arquivos fora do padrão YYYY-MM
21
+ docs_sem_data = []
22
+ # Conjunto para todos os arquivos (para listagem geral no final)
23
+ todos_arquivos = set()
24
+
25
+ # Percorre o diretório de forma recursiva
26
+ for root, dirs, files in os.walk(directory_path):
27
+ match_ano_mes = re.search(r"(\d{4})-(\d{2})", root)
28
+ if match_ano_mes:
29
+ ano = match_ano_mes.group(1)
30
+ mes = match_ano_mes.group(2)
31
+
32
+ if ano not in estrutura_anos:
33
+ estrutura_anos[ano] = {}
34
+ if mes not in estrutura_anos[ano]:
35
+ estrutura_anos[ano][mes] = []
36
+
37
+ for nome_arq in files:
38
+ estrutura_anos[ano][mes].append(nome_arq)
39
+ todos_arquivos.add(nome_arq)
40
+ else:
41
+ # Se a pasta não segue o padrão YYYY-MM, consideramos esses arquivos "sem data"
42
+ for nome_arq in files:
43
+ docs_sem_data.append(nome_arq)
44
+ todos_arquivos.add(nome_arq)
45
+
46
+ # Montamos o texto final, seguindo a lógica dos "casos"
47
+ descricao_final = []
48
+
49
+ # Organiza a lista de anos e percorre
50
+ for ano in sorted(estrutura_anos.keys()):
51
+ meses_ordenados = sorted(estrutura_anos[ano].keys())
52
+ qtd_meses = len(meses_ordenados)
53
+
54
+ # Caso 1: Apenas um mês e um arquivo
55
+ if qtd_meses == 1:
56
+ unico_mes = meses_ordenados[0]
57
+ arquivos_unico_mes = estrutura_anos[ano][unico_mes]
58
+ if len(arquivos_unico_mes) == 1:
59
+ nome_mes_extenso = mes_extenso(unico_mes)
60
+ arquivo_unico = arquivos_unico_mes[0]
61
+ descricao_final.append(
62
+ f"No ano de {ano}, temos somente o mês de {nome_mes_extenso} (mês {unico_mes}), "
63
+ f"outros meses não foram listados, e nessa pasta encontramos apenas "
64
+ f"um manual chamado {arquivo_unico}."
65
+ )
66
+ # pula para o próximo ano
67
+ continue
68
+
69
+ # Caso 2: Mais meses ou mais arquivos em algum mês
70
+ frase_inicial = f"No ano de {ano}, temos os meses de "
71
+ meses_descricao = [f"{mes_extenso(m)} (mês {m})" for m in meses_ordenados]
72
+ frase_inicial += ", ".join(meses_descricao) + "."
73
+ descricao_final.append(frase_inicial)
74
+
75
+ for mes_ in meses_ordenados:
76
+ arquivos_mes = estrutura_anos[ano][mes_]
77
+ nome_mes_extenso = mes_extenso(mes_)
78
+ qtd_arquivos = len(arquivos_mes)
79
+ if qtd_arquivos == 1:
80
+ descricao_final.append(
81
+ f"Em {nome_mes_extenso} temos somente o manual chamado {arquivos_mes[0]}."
82
+ )
83
+ else:
84
+ descricao_final.append(
85
+ f"Em {nome_mes_extenso} temos {qtd_arquivos} manuais, chamados: {', '.join(arquivos_mes)}."
86
+ )
87
+
88
+ # Caso 3: Arquivos sem data
89
+ if docs_sem_data:
90
+ docs_sem_data_unicos = list(set(docs_sem_data))
91
+ if len(docs_sem_data_unicos) == 1:
92
+ descricao_final.append(
93
+ f"Em nossos documentos, fora de pastas de data, temos somente este arquivo: {docs_sem_data_unicos[0]}."
94
+ )
95
+ else:
96
+ descricao_final.append(
97
+ "Em nossos documentos, fora de pastas de data, encontramos estes arquivos: "
98
+ + ", ".join(docs_sem_data_unicos) + "."
99
+ )
100
+
101
+ # Lista global de todos os arquivos
102
+ lista_geral_ordenada = sorted(todos_arquivos)
103
+ descricao_final.append(
104
+ "Essas são as listas de todos os documentos, manuais e organogramas que podemos "
105
+ f"resolver, listar e usar para nossas respostas, esses documentos vão ser muito úteis: {', '.join(lista_geral_ordenada)}"
106
+ )
107
+
108
+ # Une o texto final
109
+ document_text = "\n".join(descricao_final)
110
+
111
+ # Cria e retorna um Document com esse texto
112
+ document = Document(
113
+ text=document_text,
114
+ metadata={
115
+ "description": "Lista de manuais, arquivos e documentos que podemos responder.",
116
+ "file_name": "Lista de documentos",
117
+ "summary": "Entre 2011 e 2024, há uma grande variedade de manuais e documentos organizados por ano e mês: alguns anos possuem apenas um mês e um único arquivo, enquanto outros registram diversos períodos, cada um com vários manuais. Adicionalmente, há alguns arquivos “soltos”, fora dessas pastas de data. No fim, existe uma listagem completa de todos os itens, cobrindo instruções bancárias, regulamentos internos e outros materiais de suporte."
118
+ }
119
+ )
120
+ return document
121
+
122
+ def mes_extenso(mes_str: str) -> str:
123
+ """
124
+ Converte '07' em 'Julho', '09' em 'Setembro', etc.
125
+ """
126
+ meses_dict = {
127
+ "01": "Janeiro", "02": "Fevereiro", "03": "Março",
128
+ "04": "Abril", "05": "Maio", "06": "Junho",
129
+ "07": "Julho", "08": "Agosto", "09": "Setembro",
130
+ "10": "Outubro", "11": "Novembro", "12": "Dezembro"
131
+ }
132
+ return meses_dict.get(mes_str, mes_str)
133
+
drive_downloader.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ try:
2
+ import os
3
+ import io
4
+ import json
5
+ import hashlib
6
+ from googleapiclient.discovery import build
7
+ from googleapiclient.http import MediaIoBaseDownload
8
+ from google.auth.transport.requests import Request
9
+ from google.oauth2.credentials import Credentials
10
+ from tqdm import tqdm
11
+ except ImportError as e:
12
+ # Se faltarem as bibliotecas necessárias, levantamos Exception
13
+ raise Exception(
14
+ "Faltam bibliotecas necessárias para o GoogleDriveDownloader. "
15
+ "Instale-as com:\n\n"
16
+ " pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib tqdm\n\n"
17
+ f"Detalhes do erro: {str(e)}"
18
+ )
19
+
20
+ class GoogleDriveDownloader:
21
+ """
22
+ Classe para autenticar e baixar arquivos do Google Drive,
23
+ preservando a estrutura de pastas e evitando downloads redundantes.
24
+ - Nunca abrirá navegador se não encontrar token válido (apenas levanta exceção).
25
+ - Pode ler 'credentials.json' e 'token.json' do disco ou das variáveis de ambiente.
26
+ """
27
+
28
+ SCOPES = ['https://www.googleapis.com/auth/drive.readonly']
29
+
30
+ def __init__(self, chunksize=100 * 1024 * 1024):
31
+ """
32
+ :param chunksize: Tamanho (em bytes) de cada chunk ao baixar arquivos.
33
+ Ex.: 100MB = 100 * 1024 * 1024.
34
+ """
35
+ self.chunksize = chunksize
36
+ self.service = None
37
+
38
+ def _get_credentials_from_env_or_file(self):
39
+ """
40
+ Verifica se existem variáveis de ambiente para 'CREDENTIALS' e 'TOKEN'.
41
+ Caso contrário, tenta usar arquivos locais 'credentials.json' e 'token.json'.
42
+
43
+ Se o token local/ambiente não existir ou for inválido (sem refresh),
44
+ levanta exceção (não abrimos navegador neste fluxo).
45
+ """
46
+ print("Procurando credentials na variavel de ambiente...")
47
+ env_credentials = os.environ.get("CREDENTIALS") # Conteúdo JSON do client secrets
48
+ env_token = os.environ.get("TOKEN") # Conteúdo JSON do token
49
+
50
+ creds = None
51
+
52
+ # 1) Carregar credenciais do ambiente, se houver
53
+ if env_credentials:
54
+ try:
55
+ creds_json = json.loads(env_credentials)
56
+ except json.JSONDecodeError:
57
+ raise ValueError("A variável de ambiente 'CREDENTIALS' não contém JSON válido.")
58
+
59
+ # Validamos o "client_id" para garantir que seja um JSON de credenciais mesmo
60
+ client_id = (
61
+ creds_json.get("installed", {}).get("client_id") or
62
+ creds_json.get("web", {}).get("client_id")
63
+ )
64
+ if not client_id:
65
+ raise ValueError("Credenciais em memória não parecem válidas. Faltam campos 'client_id'.")
66
+
67
+ else:
68
+ # Se não há credenciais no ambiente, tentamos local
69
+ if not os.path.exists("credentials.json"):
70
+ raise FileNotFoundError(
71
+ "Nenhuma credencial encontrada em ambiente ou no arquivo 'credentials.json'."
72
+ )
73
+ print("Variavel não encontrada, usando credentials.json")
74
+ with open("credentials.json", 'r', encoding='utf-8') as f:
75
+ creds_json = json.load(f)
76
+
77
+ print("Procurando tokens na variavel de ambiente...")
78
+ token_data = None
79
+ if env_token:
80
+ try:
81
+ token_data = json.loads(env_token)
82
+ except json.JSONDecodeError:
83
+ raise ValueError("A variável de ambiente 'TOKEN' não contém JSON válido.")
84
+ else:
85
+ # Se não há token no ambiente, checamos arquivo local
86
+ if os.path.exists("token.json"):
87
+ print("Variavel não encontrada, usando token.json")
88
+ with open("token.json", 'r', encoding='utf-8') as tf:
89
+ token_data = json.load(tf)
90
+ else:
91
+ raise FileNotFoundError(
92
+ "Não há token no ambiente nem em 'token.json'. "
93
+ "Não é possível autenticar sem abrir navegador, então abortando."
94
+ )
95
+
96
+ # 3) Criar credenciais a partir do token_data
97
+ creds = Credentials.from_authorized_user_info(token_data, self.SCOPES)
98
+
99
+ # 4) Se expirou, tenta refresh
100
+ if not creds.valid:
101
+ if creds.expired and creds.refresh_token:
102
+ creds.refresh(Request())
103
+ # Salva token atualizado, se estiver usando arquivo local
104
+ if not env_token: # só sobrescreve se está lendo do disco
105
+ with open("token.json", 'w', encoding='utf-8') as token_file:
106
+ token_file.write(creds.to_json())
107
+ else:
108
+ # Se não é válido e não há refresh token, não temos como renovar sem navegador
109
+ raise RuntimeError(
110
+ "As credenciais de token são inválidas/expiradas e sem refresh token. "
111
+ "Não é possível abrir navegador neste fluxo, abortando."
112
+ )
113
+
114
+ return creds
115
+
116
+ def authenticate(self):
117
+ """Cria e armazena o serviço do Drive API nesta instância."""
118
+ creds = self._get_credentials_from_env_or_file()
119
+ self.service = build("drive", "v3", credentials=creds)
120
+
121
+ def _list_files_in_folder(self, folder_id):
122
+ """Retorna a lista de itens (arquivos/pastas) diretamente em 'folder_id'."""
123
+ items = []
124
+ page_token = None
125
+ query = f"'{folder_id}' in parents and trashed=false"
126
+
127
+ while True:
128
+ response = self.service.files().list(
129
+ q=query,
130
+ spaces='drive',
131
+ fields='nextPageToken, files(id, name, mimeType)',
132
+ pageToken=page_token
133
+ ).execute()
134
+ items.extend(response.get('files', []))
135
+ page_token = response.get('nextPageToken', None)
136
+ if not page_token:
137
+ break
138
+ return items
139
+
140
+ def _get_file_metadata(self, file_id):
141
+ """
142
+ Retorna (size, md5Checksum, modifiedTime) de um arquivo no Drive.
143
+ Se algum campo não existir, retorna valor padrão.
144
+ """
145
+ data = self.service.files().get(
146
+ fileId=file_id,
147
+ fields='size, md5Checksum, modifiedTime'
148
+ ).execute()
149
+
150
+ size = int(data.get('size', 0))
151
+ md5 = data.get('md5Checksum', '')
152
+ modified_time = data.get('modifiedTime', '')
153
+ return size, md5, modified_time
154
+
155
+ def _get_all_items_recursively(self, folder_id, parent_path=''):
156
+ """
157
+ Percorre recursivamente a pasta (folder_id) no Drive,
158
+ retornando lista de dicts (id, name, mimeType, path).
159
+ """
160
+ results = []
161
+ items = self._list_files_in_folder(folder_id)
162
+
163
+ for item in items:
164
+ current_path = os.path.join(parent_path, item['name'])
165
+ if item['mimeType'] == 'application/vnd.google-apps.folder':
166
+ results.append({
167
+ 'id': item['id'],
168
+ 'name': item['name'],
169
+ 'mimeType': item['mimeType'],
170
+ 'path': current_path
171
+ })
172
+ sub = self._get_all_items_recursively(item['id'], current_path)
173
+ results.extend(sub)
174
+ else:
175
+ results.append({
176
+ 'id': item['id'],
177
+ 'name': item['name'],
178
+ 'mimeType': item['mimeType'],
179
+ 'path': parent_path
180
+ })
181
+ return results
182
+
183
+ def _needs_download(self, local_folder, file_info):
184
+ """
185
+ Verifica se o arquivo em 'file_info' precisa ser baixado.
186
+ - Se não existir localmente, retorna True.
187
+ - Se existir, compara tamanho e MD5 (quando disponível).
188
+ - Retorna True se for diferente, False se for idêntico.
189
+ """
190
+ file_id = file_info['id']
191
+ file_name = file_info['name']
192
+ rel_path = file_info['path']
193
+
194
+ drive_size, drive_md5, _ = self._get_file_metadata(file_id)
195
+ full_local_path = os.path.join(local_folder, rel_path, file_name)
196
+
197
+ if not os.path.exists(full_local_path):
198
+ return True # Não existe localmente
199
+
200
+ local_size = os.path.getsize(full_local_path)
201
+ if local_size != drive_size:
202
+ return True
203
+
204
+ if drive_md5:
205
+ with open(full_local_path, 'rb') as f:
206
+ local_md5 = hashlib.md5(f.read()).hexdigest()
207
+ if local_md5 != drive_md5:
208
+ return True
209
+
210
+ return False
211
+
212
+ def _download_single_file(self, file_id, file_name, relative_path, progress_bar):
213
+ """
214
+ Faz download de um único arquivo do Drive, atualizando a barra de progresso global.
215
+ """
216
+ # Como fizemos 'os.chdir(local_folder)' antes, 'relative_path' pode ser vazio.
217
+ # Então concatenamos sem o local_folder:
218
+ file_path = os.path.join(relative_path, file_name)
219
+
220
+ # Se o path do diretório for vazio, cai no '.' para evitar WinError 3
221
+ dir_name = os.path.dirname(file_path) or '.'
222
+ os.makedirs(dir_name, exist_ok=True)
223
+
224
+ request = self.service.files().get_media(fileId=file_id)
225
+ with io.FileIO(file_path, 'wb') as fh:
226
+ downloader = MediaIoBaseDownload(fh, request, chunksize=self.chunksize)
227
+ done = False
228
+ previous_progress = 0
229
+
230
+ while not done:
231
+ status, done = downloader.next_chunk()
232
+ if status:
233
+ current_progress = status.resumable_progress
234
+ chunk_downloaded = current_progress - previous_progress
235
+ previous_progress = current_progress
236
+ progress_bar.update(chunk_downloaded)
237
+
238
+ def download_from_folder(self, drive_folder_id: str, local_folder: str):
239
+ """
240
+ Método principal para:
241
+ 1. Autenticar sem abrir navegador (usa token local/ambiente).
242
+ 2. Exibir "Iniciando verificação de documentos".
243
+ 3. Listar recursivamente arquivos da pasta do Drive.
244
+ 4. Verificar quais precisam de download.
245
+ 5. Baixar apenas o necessário, com barra de progresso única.
246
+ """
247
+ print("Iniciando verificação de documentos")
248
+
249
+ if not self.service:
250
+ self.authenticate()
251
+
252
+ print("Buscando lista de arquivos no Drive...")
253
+ all_items = self._get_all_items_recursively(drive_folder_id)
254
+
255
+ # Filtra apenas arquivos (exclui subpastas)
256
+ all_files = [f for f in all_items if f['mimeType'] != 'application/vnd.google-apps.folder']
257
+
258
+ print("Verificando quais arquivos precisam ser baixados...")
259
+ files_to_download = []
260
+ total_size_to_download = 0
261
+ for info in all_files:
262
+ if self._needs_download(local_folder, info):
263
+ drive_size, _, _ = self._get_file_metadata(info['id'])
264
+ total_size_to_download += drive_size
265
+ files_to_download.append(info)
266
+
267
+ if not files_to_download:
268
+ print("Nenhum arquivo novo ou atualizado. Tudo sincronizado!")
269
+ return
270
+
271
+ print("Calculando total de bytes a serem baixados...")
272
+
273
+ # Ajusta a pasta local e cria se necessário
274
+ os.makedirs(local_folder, exist_ok=True)
275
+
276
+ # Muda diretório de trabalho para simplificar criação de subpastas
277
+ old_cwd = os.getcwd()
278
+ os.chdir(local_folder)
279
+
280
+ # Cria a barra de progresso global
281
+ progress_bar = tqdm(
282
+ total=total_size_to_download,
283
+ unit='B',
284
+ unit_scale=True,
285
+ desc='Baixando arquivos'
286
+ )
287
+
288
+ # Baixa só o que precisa
289
+ for file_info in files_to_download:
290
+ self._download_single_file(
291
+ file_id=file_info['id'],
292
+ file_name=file_info['name'],
293
+ relative_path=file_info['path'],
294
+ progress_bar=progress_bar
295
+ )
296
+
297
+ progress_bar.close()
298
+ os.chdir(old_cwd)
299
+
300
+ print("Download concluído com sucesso!")
logos/logo-sicoob.jpg ADDED
pngegg.png ADDED
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ llama-index==0.12.12
2
+ llama-index-retrievers-bm25==0.5.2
3
+ llama-index-vector-stores-chroma==0.4.1
4
+ llama-index-readers-google==0.6.0
5
+ openpyxl==3.1.5
6
+ flask==3.1.0
7
+ streamlit==1.41.1
8
+ streamlit_authenticator
sicoob-logo.png ADDED