Commit
·
d368963
1
Parent(s):
1288cd7
teste funcionalidade de autenticação
Browse files- .streamlit/config.toml +3 -0
- Dockerfile +30 -0
- app.py +140 -0
- chatbot_server.py +131 -0
- config.yaml +38 -0
- document_creator.py +133 -0
- drive_downloader.py +300 -0
- logos/logo-sicoob.jpg +0 -0
- pngegg.png +0 -0
- requirements.txt +8 -0
- sicoob-logo.png +0 -0
.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 |
+
email: [email protected]
|
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 |
+
email: [email protected]
|
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 |
+
email: [email protected]
|
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
![]() |