leandroaraujodev Restodecoca commited on
Commit
a95e592
·
verified ·
1 Parent(s): 3d59f14

Codigo refatorado, variaveis de ambiente precisam estar setadas (#6)

Browse files

- Codigo refatorado, variaveis de ambiente precisam estar setadas (2f09a6ec8bb4effc64ce04b34d7ae2adb696a28a)


Co-authored-by: Gabriel Silva Rodrigues <[email protected]>

Files changed (6) hide show
  1. Dockerfile +1 -1
  2. app.py +92 -533
  3. chatbot_server.py +144 -0
  4. document_creator.py +133 -0
  5. drive_downloader.py +300 -0
  6. requirements.txt +7 -11
Dockerfile CHANGED
@@ -27,4 +27,4 @@ COPY --chown=user . $HOME/app
27
  EXPOSE 7860
28
 
29
  # Comando para iniciar o Flask em segundo plano e o Streamlit
30
- CMD ["sh", "-c", "python app.py & streamlit run app.py --server.port 7860 --server.address 0.0.0.0"]
 
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 CHANGED
@@ -1,545 +1,104 @@
1
- import logging
2
- import sys
3
- import os
4
-
5
- import re
6
- import base64
7
- import nest_asyncio
8
- nest_asyncio.apply()
9
- import pandas as pd
10
- from pathlib import Path
11
- from typing import Any, Dict, List, Optional
12
- from PIL import Image
13
  import streamlit as st
14
- import torch
15
-
16
- # Imports do LlamaIndex
17
- from llama_index.core import (
18
- Settings,
19
- SimpleDirectoryReader,
20
- StorageContext,
21
- Document
22
- )
23
-
24
- from llama_index.core.storage.docstore import SimpleDocumentStore
25
- from llama_index.core.node_parser import LangchainNodeParser
26
- from langchain.text_splitter import RecursiveCharacterTextSplitter
27
- from llama_index.core.storage.chat_store import SimpleChatStore
28
- from llama_index.core.memory import ChatMemoryBuffer
29
- from llama_index.core.query_engine import RetrieverQueryEngine
30
- from llama_index.core.chat_engine import CondensePlusContextChatEngine
31
- from llama_index.core.retrievers import QueryFusionRetriever
32
- from llama_index.vector_stores.chroma import ChromaVectorStore
33
- from llama_index.core import VectorStoreIndex
34
-
35
- import chromadb
36
-
37
- ###############################################################################
38
- # MONKEY PATCH EM bm25s #
39
- ###############################################################################
40
- import bm25s
41
- ###############################################################################
42
- # CLASSE BM25Retriever (AJUSTADA PARA ENCODING) #
43
- ###############################################################################
44
- import json
45
- import Stemmer
46
-
47
- from llama_index.core.base.base_retriever import BaseRetriever
48
- from llama_index.core.callbacks.base import CallbackManager
49
- from llama_index.core.constants import DEFAULT_SIMILARITY_TOP_K
50
- from llama_index.core.schema import (
51
- BaseNode,
52
- IndexNode,
53
- NodeWithScore,
54
- QueryBundle,
55
- MetadataMode,
56
- )
57
- from llama_index.core.vector_stores.utils import (
58
- node_to_metadata_dict,
59
- metadata_dict_to_node,
60
- )
61
- from typing import cast
62
-
63
- logger = logging.getLogger(__name__)
64
-
65
- DEFAULT_PERSIST_ARGS = {"similarity_top_k": "similarity_top_k", "_verbose": "verbose"}
66
- DEFAULT_PERSIST_FILENAME = "retriever.json"
67
-
68
-
69
- class BM25Retriever(BaseRetriever):
70
- """
71
- Implementação customizada do algoritmo BM25 com a lib bm25s, incluindo um
72
- 'monkey patch' para contornar problemas de decodificação de caracteres.
73
- """
74
-
75
- def __init__(
76
- self,
77
- nodes: Optional[List[BaseNode]] = None,
78
- stemmer: Optional[Stemmer.Stemmer] = None,
79
- language: str = "en",
80
- existing_bm25: Optional[bm25s.BM25] = None,
81
- similarity_top_k: int = DEFAULT_SIMILARITY_TOP_K,
82
- callback_manager: Optional[CallbackManager] = None,
83
- objects: Optional[List[IndexNode]] = None,
84
- object_map: Optional[dict] = None,
85
- verbose: bool = False,
86
- ) -> None:
87
- self.stemmer = stemmer or Stemmer.Stemmer("english")
88
- self.similarity_top_k = similarity_top_k
89
-
90
- if existing_bm25 is not None:
91
- # Usa instância BM25 existente
92
- self.bm25 = existing_bm25
93
- self.corpus = existing_bm25.corpus
94
- else:
95
- # Cria uma nova instância BM25 a partir de 'nodes'
96
- if nodes is None:
97
- raise ValueError("É preciso fornecer 'nodes' ou um 'existing_bm25'.")
98
 
99
- self.corpus = [node_to_metadata_dict(node) for node in nodes]
100
- corpus_tokens = bm25s.tokenize(
101
- [node.get_content(metadata_mode=MetadataMode.EMBED) for node in nodes],
102
- stopwords=language,
103
- stemmer=self.stemmer,
104
- show_progress=verbose,
105
- )
106
- self.bm25 = bm25s.BM25()
107
- self.bm25.index(corpus_tokens, show_progress=verbose)
108
 
109
- super().__init__(
110
- callback_manager=callback_manager,
111
- object_map=object_map,
112
- objects=objects,
113
- verbose=verbose,
114
- )
115
 
116
- @classmethod
117
- def from_defaults(
118
- cls,
119
- index: Optional[VectorStoreIndex] = None,
120
- nodes: Optional[List[BaseNode]] = None,
121
- docstore: Optional["BaseDocumentStore"] = None,
122
- stemmer: Optional[Stemmer.Stemmer] = None,
123
- language: str = "en",
124
- similarity_top_k: int = DEFAULT_SIMILARITY_TOP_K,
125
- verbose: bool = False,
126
- tokenizer: Optional[Any] = None,
127
- ) -> "BM25Retriever":
128
- if tokenizer is not None:
129
- logger.warning(
130
- "O parâmetro 'tokenizer' foi descontinuado e será removido "
131
- "no futuro. Use um Stemmer do PyStemmer para melhor controle."
132
  )
 
133
 
134
- if sum(bool(val) for val in [index, nodes, docstore]) != 1:
135
- raise ValueError("Passe exatamente um entre 'index', 'nodes' ou 'docstore'.")
 
 
 
 
136
 
137
- if index is not None:
138
- docstore = index.docstore
139
-
140
- if docstore is not None:
141
- nodes = cast(List[BaseNode], list(docstore.docs.values()))
142
-
143
- assert nodes is not None, (
144
- "Não foi possível determinar os nodes. Verifique seus parâmetros."
145
- )
146
-
147
- return cls(
148
- nodes=nodes,
149
- stemmer=stemmer,
150
- language=language,
151
- similarity_top_k=similarity_top_k,
152
- verbose=verbose,
153
- )
154
-
155
- def get_persist_args(self) -> Dict[str, Any]:
156
- """Dicionário com os parâmetros de persistência a serem salvos."""
157
- return {
158
- DEFAULT_PERSIST_ARGS[key]: getattr(self, key)
159
- for key in DEFAULT_PERSIST_ARGS
160
- if hasattr(self, key)
161
- }
162
-
163
- def persist(self, path: str, **kwargs: Any) -> None:
164
  """
165
- Persiste o retriever em um diretório, incluindo
166
- a estrutura do BM25 e o corpus em JSON.
167
  """
168
- self.bm25.save(path, corpus=self.corpus, **kwargs)
169
- with open(
170
- os.path.join(path, DEFAULT_PERSIST_FILENAME),
171
- "wt",
172
- encoding="utf-8",
173
- errors="ignore",
174
- ) as f:
175
- json.dump(self.get_persist_args(), f, indent=2, ensure_ascii=False)
 
 
 
 
 
 
 
 
176
 
177
- @classmethod
178
- def from_persist_dir(cls, path: str, **kwargs: Any) -> "BM25Retriever":
179
  """
180
- Carrega o retriever de um diretório, incluindo o BM25 e o corpus.
181
- Devido ao nosso patch, ignoramos qualquer erro de decodificação
182
- que eventualmente apareça.
183
  """
184
- bm25_obj = bm25s.BM25.load(path, load_corpus=True, **kwargs)
185
- with open(
186
- os.path.join(path, DEFAULT_PERSIST_FILENAME),
187
- "rt",
188
- encoding="utf-8",
189
- errors="ignore",
190
- ) as f:
191
- retriever_data = json.load(f)
192
-
193
- return cls(existing_bm25=bm25_obj, **retriever_data)
194
-
195
- def _retrieve(self, query_bundle: QueryBundle) -> List[NodeWithScore]:
196
- """Recupera nós relevantes a partir do BM25."""
197
- query = query_bundle.query_str
198
- tokenized_query = bm25s.tokenize(
199
- query, stemmer=self.stemmer, show_progress=self._verbose
200
- )
201
- indexes, scores = self.bm25.retrieve(
202
- tokenized_query, k=self.similarity_top_k, show_progress=self._verbose
203
- )
204
-
205
- # bm25s retorna lista de listas, pois suporta batched queries
206
- indexes = indexes[0]
207
- scores = scores[0]
208
-
209
- nodes: List[NodeWithScore] = []
210
- for idx, score in zip(indexes, scores):
211
- if isinstance(idx, dict):
212
- node = metadata_dict_to_node(idx)
213
- else:
214
- node_dict = self.corpus[int(idx)]
215
- node = metadata_dict_to_node(node_dict)
216
-
217
- nodes.append(NodeWithScore(node=node, score=float(score)))
218
-
219
- return nodes
220
-
221
-
222
- ###############################################################################
223
- # CONFIGURAÇÃO STREAMLIT E AJUSTES DA PIPELINE #
224
- ###############################################################################
225
- # Evite reindexar ou baixar dados repetidamente armazenando o estado na sessão.
226
- im = Image.open("pngegg.png")
227
- st.set_page_config(page_title="Chatbot Carômetro", page_icon=im, layout="wide")
228
-
229
- # Seções laterais (sidebar)
230
- st.sidebar.title("Configuração de LLM")
231
- sidebar_option = st.sidebar.radio("Selecione o LLM", ["gpt-3.5-turbo"])
232
-
233
- import base64
234
- with open("sicoob-logo.png", "rb") as f:
235
- data = base64.b64encode(f.read()).decode("utf-8")
236
- st.sidebar.markdown(
237
- f"""
238
- <div style="display:table;margin-top:-80%;margin-left:0%;">
239
- <img src="data:image/png;base64,{data}" width="250" height="70">
240
- </div>
241
- """,
242
- unsafe_allow_html=True,
243
- )
244
-
245
- if sidebar_option == "gpt-3.5-turbo":
246
- from llama_index.llms.openai import OpenAI
247
- from llama_index.embeddings.openai import OpenAIEmbedding
248
- Settings.llm = OpenAI(model="gpt-3.5-turbo")
249
- Settings.embed_model = OpenAIEmbedding(model_name="text-embedding-ada-002")
250
- else:
251
- raise Exception("Opção de LLM inválida!")
252
-
253
- logging.basicConfig(stream=sys.stdout, level=logging.INFO)
254
- logging.getLogger().addHandler(logging.StreamHandler(stream=sys.stdout))
255
-
256
- # Caminhos principais
257
- chat_store_path = os.path.join("chat_store", "chat_store.json")
258
- documents_path = "documentos"
259
- chroma_storage_path = "chroma_db"
260
- bm25_persist_path = "bm25_retriever"
261
-
262
- # Classe CSV customizada
263
- class CustomPandasCSVReader:
264
- """PandasCSVReader modificado para incluir cabeçalhos nos documentos."""
265
- def __init__(
266
- self,
267
- *args: Any,
268
- concat_rows: bool = True,
269
- col_joiner: str = ", ",
270
- row_joiner: str = "\n",
271
- pandas_config: dict = {},
272
- **kwargs: Any
273
- ) -> None:
274
- self._concat_rows = concat_rows
275
- self._col_joiner = col_joiner
276
- self._row_joiner = row_joiner
277
- self._pandas_config = pandas_config
278
-
279
- def load_data(
280
- self,
281
- file: Path,
282
- extra_info: Optional[Dict] = None,
283
- ) -> List[Document]:
284
- df = pd.read_csv(file, **self._pandas_config)
285
- text_list = [" ".join(df.columns.astype(str))]
286
- text_list += (
287
- df.astype(str)
288
- .apply(lambda row: self._col_joiner.join(row.values), axis=1)
289
- .tolist()
290
- )
291
-
292
- metadata = {"filename": file.name, "extension": file.suffix}
293
- if extra_info:
294
- metadata.update(extra_info)
295
-
296
- if self._concat_rows:
297
- return [Document(text=self._row_joiner.join(text_list), metadata=metadata)]
298
- else:
299
- return [
300
- Document(text=text, metadata=metadata)
301
- for text in text_list
302
- ]
303
-
304
- def clean_documents(documents: List[Document]) -> List[Document]:
305
- """Remove caracteres indesejados diretamente nos textos."""
306
- cleaned_docs = []
307
- for doc in documents:
308
- cleaned_text = re.sub(r"[^0-9A-Za-zÀ-ÿ ]", "", doc.get_content())
309
- doc.text = cleaned_text
310
- cleaned_docs.append(doc)
311
- return cleaned_docs
312
-
313
- def are_docs_downloaded(directory_path: str) -> bool:
314
- """Verifica se o diretório tem algum arquivo."""
315
- return os.path.isdir(directory_path) and any(os.scandir(directory_path))
316
-
317
- # Simula a leitura de arquivos do Google Drive
318
- from llama_index.readers.google import GoogleDriveReader
319
- import json
320
-
321
- credentials_json = os.getenv('GOOGLE_CREDENTIALS')
322
- token_json = os.getenv('GOOGLE_TOKEN')
323
-
324
- if credentials_json is None:
325
- raise ValueError("The GOOGLE_CREDENTIALS environment variable is not set.")
326
-
327
- # Write the credentials to a file
328
- credentials_path = "credentials.json"
329
- token_path = "token.json"
330
- with open(credentials_path, 'w') as credentials_file:
331
- credentials_file.write(credentials_json)
332
-
333
- with open(token_path, 'w') as credentials_file:
334
- credentials_file.write(token_json)
335
-
336
- google_drive_reader = GoogleDriveReader(credentials_path=credentials_path)
337
- google_drive_reader._creds = google_drive_reader._get_credentials()
338
-
339
- def download_original_files_from_folder(
340
- greader: GoogleDriveReader,
341
- pasta_documentos_drive: str,
342
- local_path: str
343
- ):
344
- """Faz download dos arquivos apenas se não existirem localmente."""
345
- os.makedirs(local_path, exist_ok=True)
346
- files_meta = greader._get_fileids_meta(folder_id=pasta_documentos_drive)
347
- if not files_meta:
348
- logging.info("Nenhum arquivo encontrado na pasta especificada.")
349
- return
350
- for fmeta in files_meta:
351
- file_id = fmeta[0]
352
- file_name = os.path.basename(fmeta[2])
353
- local_file_path = os.path.join(local_path, file_name)
354
-
355
- if os.path.exists(local_file_path):
356
- logging.info(f"Arquivo '{file_name}' já existe localmente, ignorando download.")
357
- continue
358
-
359
- downloaded_file_path = greader._download_file(file_id, local_file_path)
360
- if downloaded_file_path:
361
- logging.info(f"Arquivo '{file_name}' baixado com sucesso em: {downloaded_file_path}")
362
- else:
363
- logging.warning(f"Não foi possível baixar '{file_name}'")
364
-
365
- # Pasta do Drive
366
- pasta_documentos_drive = "1s0UUANcU1B0D2eyRweb1W5idUn1V5JEh"
367
-
368
- ###############################################################################
369
- # CRIAÇÃO/CARREGAMENTO DE RECURSOS (evita repetição de etapas) #
370
- ###############################################################################
371
- # 1. Garantir que não baixamos dados novamente se eles já existem.
372
- if not are_docs_downloaded(documents_path):
373
- logging.info("Baixando arquivos originais do Drive para 'documentos'...")
374
- download_original_files_from_folder(
375
- google_drive_reader,
376
- pasta_documentos_drive,
377
- documents_path
378
- )
379
- else:
380
- logging.info("'documentos' já contém arquivos, ignorando download.")
381
-
382
- # 2. Se ainda não existir docstore e index no estado da sessão, criamos.
383
- # Caso contrário, apenas reutilizamos o que já existe.
384
- if "docstore" not in st.session_state:
385
- # Carregar documentos do diretório local
386
- file_extractor = {".csv": CustomPandasCSVReader()}
387
- documents = SimpleDirectoryReader(
388
- input_dir=documents_path,
389
- file_extractor=file_extractor,
390
- filename_as_id=True,
391
- recursive=True
392
- ).load_data()
393
-
394
- documents = clean_documents(documents)
395
-
396
- # Cria docstore
397
- docstore = SimpleDocumentStore()
398
- docstore.add_documents(documents)
399
-
400
- st.session_state["docstore"] = docstore
401
- else:
402
- docstore = st.session_state["docstore"]
403
-
404
- # 3. Configuramos o VectorStore + Chroma sem recriar se já estiver pronto.
405
- if "vector_store" not in st.session_state:
406
- db = chromadb.PersistentClient(path=chroma_storage_path)
407
- chroma_collection = db.get_or_create_collection("dense_vectors")
408
- vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
409
- st.session_state["vector_store"] = vector_store
410
- else:
411
- vector_store = st.session_state["vector_store"]
412
-
413
- storage_context = StorageContext.from_defaults(
414
- docstore=docstore,
415
- vector_store=vector_store
416
- )
417
-
418
- # 4. Carregamos ou criamos o índice. Se já existe a base do Chroma, supõe-se
419
- # que o índice foi persistido. Caso contrário, cria-se.
420
- if "index" not in st.session_state:
421
- if os.path.exists(chroma_storage_path) and os.listdir(chroma_storage_path):
422
- # Há dados salvos, então criamos índice a partir do vector_store
423
- index = VectorStoreIndex.from_vector_store(vector_store)
424
- else:
425
- # Cria índice (chunk_size pode ser configurado conforme necessidade)
426
- splitter = LangchainNodeParser(
427
- RecursiveCharacterTextSplitter(chunk_size=1024, chunk_overlap=128)
428
- )
429
- index = VectorStoreIndex.from_documents(
430
- list(docstore.docs.values()),
431
- storage_context=storage_context,
432
- transformations=[splitter]
433
- )
434
- vector_store.persist()
435
- st.session_state["index"] = index
436
- else:
437
- index = st.session_state["index"]
438
-
439
- # 5. Criação ou carregamento do BM25Retriever customizado
440
- if "bm25_retriever" not in st.session_state:
441
- if (
442
- os.path.exists(bm25_persist_path)
443
- and os.path.exists(os.path.join(bm25_persist_path, "bm25.index.json"))
444
- ):
445
- bm25_retriever = BM25Retriever.from_persist_dir(bm25_persist_path)
446
- else:
447
- bm25_retriever = BM25Retriever.from_defaults(
448
- docstore=docstore,
449
- similarity_top_k=2,
450
- language="portuguese",
451
- verbose=True
452
- )
453
- os.makedirs(bm25_persist_path, exist_ok=True)
454
- bm25_retriever.persist(bm25_persist_path)
455
- st.session_state["bm25_retriever"] = bm25_retriever
456
- else:
457
- bm25_retriever = st.session_state["bm25_retriever"]
458
-
459
- # 6. Criamos ou recuperamos o retriever que fará Query Fusion (BM25 + eventual vetor)
460
- if "fusion_retriever" not in st.session_state:
461
- vector_retriever = index.as_retriever(similarity_top_k=2)
462
- fusion_retriever = QueryFusionRetriever(
463
- [bm25_retriever, vector_retriever],
464
- similarity_top_k=2,
465
- num_queries=0,
466
- mode="reciprocal_rerank",
467
- use_async=True,
468
- verbose=True,
469
- query_gen_prompt=(
470
- "Gere {num_queries} perguntas de busca relacionadas à seguinte pergunta. "
471
- "Priorize o significado da pergunta sobre qualquer histórico de conversa. "
472
- "Se o histórico não for relevante, ignore-o. "
473
- "Não adicione explicações ou introduções. Apenas escreva as perguntas. "
474
- "Pergunta: {query}\n\nPerguntas:\n"
475
- ),
476
- )
477
- st.session_state["fusion_retriever"] = fusion_retriever
478
- else:
479
- fusion_retriever = st.session_state["fusion_retriever"]
480
-
481
- # 7. Configura o Chat Engine caso ainda não esteja na sessão
482
- if "chat_engine" not in st.session_state:
483
- nest_asyncio.apply()
484
- memory = ChatMemoryBuffer.from_defaults(token_limit=3900)
485
- query_engine = RetrieverQueryEngine.from_args(fusion_retriever)
486
-
487
- chat_engine = CondensePlusContextChatEngine.from_defaults(
488
- query_engine,
489
- memory=memory,
490
- context_prompt=(
491
- "Você é um assistente virtual capaz de interagir normalmente, além de "
492
- "fornecer informações sobre organogramas e listar funcionários. "
493
- "Aqui estão os documentos relevantes para o contexto:\n"
494
- "{context_str}\n"
495
- "Use o histórico anterior ou o contexto acima para responder."
496
- ),
497
- verbose=True,
498
- )
499
- st.session_state["chat_engine"] = chat_engine
500
- else:
501
- chat_engine = st.session_state["chat_engine"]
502
-
503
- # 8. Armazenamento do chat
504
- if "chat_store" not in st.session_state:
505
- if os.path.exists(chat_store_path):
506
- chat_store = SimpleChatStore.from_persist_path(persist_path=chat_store_path)
507
- else:
508
- chat_store = SimpleChatStore()
509
- chat_store.persist(persist_path=chat_store_path)
510
- st.session_state["chat_store"] = chat_store
511
- else:
512
- chat_store = st.session_state["chat_store"]
513
-
514
-
515
- ###############################################################################
516
- # INTERFACE DO CHAT EM STREAMLIT #
517
- ###############################################################################
518
- st.title("Chatbot Carômetro")
519
- st.write("Este assistente virtual pode te ajudar a encontrar informações relevantes sobre os carômetros da Sicoob.")
520
-
521
- if 'chat_history' not in st.session_state:
522
- st.session_state.chat_history = []
523
-
524
- for message in st.session_state.chat_history:
525
- role, text = message.split(":", 1)
526
- with st.chat_message(role.strip().lower()):
527
- st.write(text.strip())
528
-
529
- user_input = st.chat_input("Digite sua pergunta")
530
- if user_input:
531
- with st.chat_message('user'):
532
- st.write(user_input)
533
- st.session_state.chat_history.append(f"user: {user_input}")
534
-
535
- with st.chat_message('assistant'):
536
- message_placeholder = st.empty()
537
- assistant_message = ''
538
-
539
- response = chat_engine.stream_chat(user_input)
540
- for token in response.response_gen:
541
- assistant_message += token
542
- message_placeholder.markdown(assistant_message + "▌")
543
-
544
- message_placeholder.markdown(assistant_message)
545
- st.session_state.chat_history.append(f"assistant: {assistant_message}")
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import streamlit as st
2
+ import requests
3
+ from PIL import Image
4
+ import base64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
 
 
 
 
 
 
 
 
 
6
 
7
+ class ChatbotApp:
8
+ def __init__(self):
9
+ # URL do backend (Flask)
10
+ self.backend_url = "http://localhost:5001/chat"
11
+ self.title = "Chatbot Carômetro"
12
+ self.description = "Este assistente virtual pode te ajudar com informações sobre carômetros da Sicoob."
13
 
14
+ def stream_chat(self, user_input):
15
+ """
16
+ Faz a comunicação com o backend e retorna a resposta como streaming de tokens.
17
+ """
18
+ try:
19
+ response = requests.post(
20
+ self.backend_url,
21
+ json={"message": user_input},
22
+ stream=True # Ativa o streaming
 
 
 
 
 
 
 
23
  )
24
+ response.raise_for_status()
25
 
26
+ # Gera os tokens conforme chegam no streaming
27
+ for chunk in response.iter_content(chunk_size=512):
28
+ if chunk:
29
+ yield chunk.decode("utf-8")
30
+ except Exception as e:
31
+ yield f"Erro ao conectar ao servidor: {e}"
32
 
33
+ def render_sidebar(self):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  """
35
+ Exibe opções na barra lateral e renderiza a logo do Sicoob.
 
36
  """
37
+ st.sidebar.title("Configuração de LLM")
38
+ sidebar_option = st.sidebar.radio("Selecione o LLM", ["gpt-3.5-turbo"])
39
+ if sidebar_option != "gpt-3.5-turbo":
40
+ raise Exception("Opção de LLM inválida!")
41
+
42
+ # Exibe a logo do Sicoob na barra lateral
43
+ with open("sicoob-logo.png", "rb") as f:
44
+ data = base64.b64encode(f.read()).decode("utf-8")
45
+ st.sidebar.markdown(
46
+ f"""
47
+ <div style="display:table;margin-top:-80%;margin-left:0%;">
48
+ <img src="data:image/png;base64,{data}" width="250" height="70">
49
+ </div>
50
+ """,
51
+ unsafe_allow_html=True,
52
+ )
53
 
54
+ def render(self):
 
55
  """
56
+ Renderiza a interface do chatbot.
 
 
57
  """
58
+ # Configura título, ícone e layout da página
59
+ im = Image.open("pngegg.png")
60
+ st.set_page_config(page_title="Chatbot Carômetro", page_icon=im, layout="wide")
61
+
62
+ # Renderiza a barra lateral
63
+ self.render_sidebar()
64
+
65
+ # Título e descrição
66
+ st.title(self.title)
67
+ st.write(self.description)
68
+
69
+ # Inicializa o histórico na sessão
70
+ if "chat_history" not in st.session_state:
71
+ st.session_state.chat_history = []
72
+
73
+ # Renderiza as mensagens do histórico
74
+ for message in st.session_state.chat_history:
75
+ role, text = message.split(":", 1)
76
+ with st.chat_message(role.strip().lower()):
77
+ st.write(text.strip())
78
+
79
+ # Captura o input do usuário
80
+ user_input = st.chat_input("Digite sua pergunta")
81
+ if user_input:
82
+ # Exibe a mensagem do usuário
83
+ with st.chat_message("user"):
84
+ st.write(user_input)
85
+ st.session_state.chat_history.append(f"user: {user_input}")
86
+
87
+ # Placeholder para a resposta do assistente
88
+ with st.chat_message("assistant"):
89
+ message_placeholder = st.empty()
90
+ assistant_message = ""
91
+
92
+ # Executa o streaming de tokens enquanto o backend responde
93
+ for token in self.stream_chat(user_input):
94
+ assistant_message += token
95
+ message_placeholder.markdown(assistant_message + "▌")
96
+
97
+ # Atualiza o placeholder com a mensagem final
98
+ message_placeholder.markdown(assistant_message)
99
+ st.session_state.chat_history.append(f"assistant: {assistant_message}")
100
+
101
+
102
+ if __name__ == "__main__":
103
+ chatbot_app = ChatbotApp()
104
+ chatbot_app.render()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
chatbot_server.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ #from llama_index.core.extractors import (
50
+ # SummaryExtractor,
51
+ #)
52
+ #summary = SummaryExtractor(llm = OpenAI(model="gpt-3.5-turbo", system_prompt "Sempre escreva e responda em português brasileiro."))
53
+ #pipeline = IngestionPipeline(
54
+ # transformations=[splitter, summary]
55
+ #)
56
+
57
+ #nodes = pipeline.run(
58
+ # documents=documents,
59
+ # in_place=True,
60
+ # show_progress=True,
61
+ #)
62
+
63
+ from llama_index.core.storage.docstore import SimpleDocumentStore
64
+ docstore = SimpleDocumentStore()
65
+ docstore.add_documents(nodes)
66
+
67
+ from llama_index.core import VectorStoreIndex, StorageContext
68
+ from llama_index.vector_stores.chroma import ChromaVectorStore
69
+ import chromadb
70
+
71
+ db = chromadb.PersistentClient(path="./storage/chroma_db")
72
+ chroma_collection = db.get_or_create_collection("dense_vectors")
73
+ vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
74
+ storage_context = StorageContext.from_defaults(
75
+ docstore=docstore, vector_store=vector_store
76
+ )
77
+ index = VectorStoreIndex(nodes = nodes, storage_context=storage_context, show_progress = True)
78
+
79
+ storage_context.docstore.persist("./storage/docstore.json")
80
+
81
+ index_retriever = index.as_retriever(similarity_top_k=2)
82
+ import nest_asyncio
83
+ nest_asyncio.apply()
84
+ from llama_index.retrievers.bm25 import BM25Retriever
85
+ bm25_retriever = BM25Retriever.from_defaults(
86
+ docstore=index.docstore,
87
+ similarity_top_k=2,
88
+ language = "portuguese",
89
+ verbose=True,
90
+ )
91
+
92
+ from llama_index.core.retrievers import QueryFusionRetriever
93
+
94
+ retriever = QueryFusionRetriever(
95
+ [index_retriever, bm25_retriever],
96
+ num_queries=1, #desativado = 1
97
+ mode="reciprocal_rerank",
98
+ use_async=True,
99
+ verbose=True,
100
+ )
101
+
102
+ from llama_index.core.storage.chat_store import SimpleChatStore
103
+ from llama_index.core.memory import ChatMemoryBuffer
104
+ chat_store = SimpleChatStore()
105
+ chat_memory = ChatMemoryBuffer.from_defaults(
106
+ token_limit=3000,
107
+ chat_store=chat_store,
108
+ chat_store_key="user1",
109
+ )
110
+ from llama_index.core.query_engine import RetrieverQueryEngine
111
+ query_engine = RetrieverQueryEngine.from_args(retriever)
112
+ from llama_index.core.chat_engine import CondensePlusContextChatEngine
113
+ chat_engine = CondensePlusContextChatEngine.from_defaults(
114
+ query_engine,
115
+ memory=chat_memory,
116
+ context_prompt=(
117
+ "Você é um assistente virtual capaz de interagir normalmente, além de"
118
+ " fornecer informações sobre organogramas e listar funcionários."
119
+ " Aqui estão os documentos relevantes para o contexto:\n"
120
+ "{context_str}"
121
+ "\nInstrução: Use o histórico da conversa anterior, ou o contexto acima, para responder."
122
+ ),
123
+ )
124
+
125
+
126
+
127
+ @app.route("/chat", methods=["POST"])
128
+ def chat():
129
+ user_input = request.json.get("message", "")
130
+ if not user_input:
131
+ return jsonify({"error": "Mensagem vazia"}), 400
132
+
133
+ def generate_response():
134
+ try:
135
+ response = chat_engine.stream_chat(user_input)
136
+ for token in response.response_gen:
137
+ yield token # Envia cada token
138
+ chat_store.persist(persist_path="./storage/chat_store.json")
139
+ except Exception as e:
140
+ yield f"Erro: {str(e)}"
141
+
142
+ return Response(generate_response(), content_type="text/plain")
143
+ if __name__ == "__main__":
144
+ app.run(port=5001, debug=False)
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!")
requirements.txt CHANGED
@@ -1,11 +1,7 @@
1
- transformers[torch]
2
- llama_index==0.11.23
3
- chromadb==0.5.18
4
- langchain==0.3.7
5
- nest_asyncio==1.6.0
6
- llama-index-retrievers-bm25==0.4.0
7
- bm25s==0.2.6
8
- llama-index-vector-stores-chroma==0.3.0
9
- openpyxl
10
- llama-index-readers-file
11
- llama-index-readers-google
 
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