Spaces:
Sleeping
Sleeping
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]>
- Dockerfile +1 -1
- app.py +92 -533
- chatbot_server.py +144 -0
- document_creator.py +133 -0
- drive_downloader.py +300 -0
- 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
|
|
|
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
|
15 |
-
|
16 |
-
|
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 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
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 |
-
|
135 |
-
|
|
|
|
|
|
|
|
|
136 |
|
137 |
-
|
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 |
-
|
166 |
-
a estrutura do BM25 e o corpus em JSON.
|
167 |
"""
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
"
|
172 |
-
|
173 |
-
|
174 |
-
) as f:
|
175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
176 |
|
177 |
-
|
178 |
-
def from_persist_dir(cls, path: str, **kwargs: Any) -> "BM25Retriever":
|
179 |
"""
|
180 |
-
|
181 |
-
Devido ao nosso patch, ignoramos qualquer erro de decodificação
|
182 |
-
que eventualmente apareça.
|
183 |
"""
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
""
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
#
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
#
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
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 |
-
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
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
|
|
|
|
|
|
|
|