andfrca commited on
Commit
ad0333a
·
verified ·
1 Parent(s): a767c32

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +253 -38
src/streamlit_app.py CHANGED
@@ -1,40 +1,255 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
4
  import streamlit as st
 
 
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging, re
2
+ from io import BytesIO
 
3
  import streamlit as st
4
+ import fitz # PyMuPDF
5
+ from collections import defaultdict, Counter
6
 
7
+ # === Logs silenciosos para a demo ===
8
+ logging.basicConfig(level=logging.WARNING)
9
+ for n in ("httpx","httpcore","langchain","llama_index","llama_index.core"):
10
+ lg = logging.getLogger(n); lg.setLevel(logging.WARNING); lg.propagate = False
11
+
12
+ # ===== LangChain (APIs atuais) =====
13
+ from typing import Any, List, Optional
14
+ from langchain_ollama import ChatOllama
15
+ from langchain_core.documents import Document as LCDocument
16
+ from langchain_core.prompts import ChatPromptTemplate
17
+ from langchain_core.retrievers import BaseRetriever
18
+ from langchain_core.callbacks import CallbackManagerForRetrieverRun
19
+ from langchain.chains.combine_documents import create_stuff_documents_chain
20
+ from langchain.chains import create_retrieval_chain
21
+
22
+ # ===== LlamaIndex (camada de dados) =====
23
+ from llama_index.core import VectorStoreIndex, Document as LIDocument
24
+ from llama_index.core.node_parser import SentenceSplitter
25
+ from llama_index.embeddings.ollama import OllamaEmbedding
26
+
27
+ # ---------------------------
28
+ # PDF -> texto
29
+ # ---------------------------
30
+ def pdf_to_text(file_bytes: bytes) -> str:
31
+ doc = fitz.open(stream=BytesIO(file_bytes), filetype="pdf")
32
+ texts = []
33
+ for page in doc:
34
+ t = page.get_text("text")
35
+ if t: texts.append(t)
36
+ return "\n".join(texts)
37
+
38
+ # ---------------------------
39
+ # Seções estilo PubMed (heurística leve)
40
+ # ---------------------------
41
+ HEAD_RX = re.compile(
42
+ r"^\s*(\d+(\.\d+)?\s+)?("
43
+ r"ABSTRACT|RESUMO|BACKGROUND|INTRODUCTION|INTRODUÇÃO|METHODS?|MATERIALS AND METHODS|MÉTODOS|"
44
+ r"RESULTS?|DISCUSSION|DISCUSSÃO|CONCLUSION(S)?|CONCLUSÕES|ACKNOWLEDGMENTS|AGRADECIMENTOS|"
45
+ r"REFERENCES|REFERÊNCIAS|BIBLIOGRAPHY"
46
+ r")\s*:?\s*$",
47
+ re.IGNORECASE | re.MULTILINE,
48
+ )
49
+
50
+ def _canon(h: str) -> str:
51
+ u = h.upper()
52
+ if "ABSTRACT" in u or "RESUMO" in u: return "Abstract"
53
+ if "BACKGROUND" in u: return "Background"
54
+ if "INTRODU" in u: return "Introduction"
55
+ if "METHOD" in u or "MATERIALS" in u or "MÉTODO" in u: return "Methods"
56
+ if "RESULT" in u: return "Results"
57
+ if "DISCUSS" in u: return "Discussion"
58
+ if "CONCLUSION" in u or "CONCLUS" in u: return "Conclusions"
59
+ if "ACKNOWLEDG" in u or "AGRADEC" in u: return "Acknowledgments"
60
+ if "REFER" in u or "BIBLIO" in u: return "References"
61
+ return h.title()
62
+
63
+ def split_pubmed_sections(full_text: str) -> dict:
64
+ lines = full_text.splitlines()
65
+ idxs = [i for i, line in enumerate(lines) if HEAD_RX.match(line.strip())]
66
+ if not idxs:
67
+ return {"Body": full_text}
68
+ idxs.append(len(lines))
69
+ out = {}
70
+ for i in range(len(idxs)-1):
71
+ header = lines[idxs[i]].strip()
72
+ body = "\n".join(lines[idxs[i]+1: idxs[i+1]]).strip()
73
+ if body:
74
+ out[_canon(header)] = (out.get(_canon(header), "") + ("\n" if _canon(header) in out else "") + body).strip()
75
+ return out or {"Body": full_text}
76
+
77
+ # ---------------------------
78
+ # Referências (heurística simples para PDF)
79
+ # ---------------------------
80
+ HDR_REF = re.compile(r"(?mi)^\s*(REFERENCES|REFERÊNCIAS|BIBLIOGRAPHY)\s*:?\s*$")
81
+ def extract_references(text: str) -> list[str]:
82
+ m = HDR_REF.search(text)
83
+ if not m:
84
+ return []
85
+ block = text[m.end():].strip()
86
+ block = re.sub(r"-\s*\n\s*", "", block) # des-hifenizar
87
+ block = re.sub(r"(?<!\n)\n(?!\n)", " ", block) # juntar linhas simples
88
+ parts = re.split(r"(?m)^\s*(?:\[\d+\]|\d+[.)-])\s+|\n\s*\n", block)
89
+ refs, seen = [], set()
90
+ for p in parts:
91
+ s = p.strip().strip(" .;")
92
+ if len(s) >= 30:
93
+ k = s.lower()[:160]
94
+ if k not in seen:
95
+ refs.append(s); seen.add(k)
96
+ return refs
97
+
98
+ # ---------------------------
99
+ # LlamaIndex -> BaseRetriever (aplica filtro por seção pós-retrieval)
100
+ # ---------------------------
101
+ class LlamaIndexRetriever(BaseRetriever):
102
+ li_retriever: Any
103
+ section_eq: Optional[str] = None
104
+
105
+ def _get_relevant_documents(
106
+ self, query: str, *, run_manager: Optional[CallbackManagerForRetrieverRun] = None
107
+ ) -> List[LCDocument]:
108
+ results = self.li_retriever.retrieve(query)
109
+ docs: List[LCDocument] = []
110
+ for r in results:
111
+ node = getattr(r, "node", r)
112
+ text = getattr(node, "get_content", lambda: None)() or getattr(node, "text", "") or ""
113
+ meta = dict(getattr(node, "metadata", {}) or {})
114
+ # filtro pós-retrieval por seção (robusto com SimpleVectorStore)
115
+ if self.section_eq and meta.get("section") != self.section_eq:
116
+ continue
117
+ docs.append(LCDocument(page_content=text, metadata=meta))
118
+ return docs
119
+
120
+ async def _aget_relevant_documents(
121
+ self, query: str, *, run_manager: Optional[CallbackManagerForRetrieverRun] = None
122
+ ) -> List[LCDocument]:
123
+ return self._get_relevant_documents(query, run_manager=run_manager)
124
+
125
+ # ---------------------------
126
+ # Construir índice (LlamaIndex)
127
+ # ---------------------------
128
+ def build_index_from_sections(sections: dict, source_name: str, chunk_size=1200, overlap=150):
129
+ li_docs = []
130
+ for sec, text in sections.items():
131
+ if text and len(text.strip()) >= 20:
132
+ li_docs.append(LIDocument(text=text, metadata={"section": sec, "source": source_name}))
133
+ nodes = SentenceSplitter(
134
+ chunk_size=chunk_size, chunk_overlap=overlap, paragraph_separator="\n\n"
135
+ ).get_nodes_from_documents(li_docs)
136
+ index = VectorStoreIndex(nodes, embed_model=OllamaEmbedding("nomic-embed-text"))
137
+ return index
138
+
139
+ # ---------------------------
140
+ # Prompt PT-BR (para create_stuff_documents_chain)
141
+ # ---------------------------
142
+ RAG_PROMPT = ChatPromptTemplate.from_template(
143
+ "Você é um assistente para revisão rápida de literatura, em PT-BR.\n"
144
+ "Responda de forma objetiva **APENAS** com base no CONTEXTO do documento.\n"
145
+ "Se faltar evidência, responda 'Não sei'. .\n\n"
146
+ "Pergunta: {input}\n\n"
147
+ "CONTEXTO:\n{context}\n\nResposta:"
148
+ )
149
+
150
+ # ---------------------------
151
+ # Streamlit UI
152
+ # ---------------------------
153
+ st.set_page_config(page_title="Revisão Rápida — LangChain + LlamaIndex + Ollama", page_icon="📄", layout="centered")
154
+ st.title("📄 Revisão Rápida de Literatura — LangChain + LlamaIndex + Ollama")
155
+
156
+ with st.sidebar:
157
+ st.markdown("**Como usar**")
158
+ st.markdown("1) Envie 1 PDF com **texto selecionável**.\n2) Clique **Processar**.\n3) Pergunte.")
159
+ st.divider()
160
+ chunk_size = st.slider("chunk_size", 600, 2000, 1200, 100)
161
+ overlap = st.slider("overlap", 0, 400, 150, 10)
162
+ top_k = st.slider("top_k (trechos)", 1, 12, 6, 1)
163
+ st.caption("Dica: ↑k = mais completude; ↓k = mais foco.")
164
+
165
+ uploaded = st.file_uploader("Envie um artigo (PDF)", type=["pdf"])
166
+
167
+ if "index" not in st.session_state:
168
+ st.session_state.index = None
169
+ st.session_state.sections = {}
170
+ st.session_state.refs = []
171
+ st.session_state.source_name = ""
172
+
173
+ col1, col2 = st.columns(2)
174
+ with col1:
175
+ if st.button("⚙️ Processar", use_container_width=True):
176
+ if not uploaded:
177
+ st.warning("Envie um PDF primeiro.")
178
+ else:
179
+ raw = uploaded.getvalue()
180
+ text = pdf_to_text(raw)
181
+ if not text or len(text.strip()) < 100:
182
+ st.error("Não foi possível extrair texto (PDF pode estar escaneado).")
183
+ else:
184
+ secs = split_pubmed_sections(text)
185
+ index = build_index_from_sections(secs, uploaded.name, chunk_size, overlap)
186
+ st.session_state.index = index
187
+ st.session_state.sections = secs
188
+ st.session_state.refs = extract_references(text)
189
+ st.session_state.source_name = uploaded.name
190
+ st.success("Artigo processado e indexado.")
191
+
192
+ with col2:
193
+ if st.session_state.index:
194
+ st.success("Pronto para perguntas.")
195
+ else:
196
+ st.info("Aguardando processamento…")
197
+
198
+ st.divider()
199
+
200
+ # Seções detectadas
201
+ if st.session_state.sections:
202
+ st.subheader("Seções detectadas")
203
+ st.write(" • ".join(f"`{k}`" for k in st.session_state.sections.keys()))
204
+
205
+ # Referências extraídas
206
+ if st.session_state.refs:
207
+ st.subheader("Referências (heurística de PDF)")
208
+ for i, r in enumerate(st.session_state.refs, 1):
209
+ st.markdown(f"{i}. {r}")
210
+
211
+ # Q&A
212
+ st.subheader("Pergunte ao artigo")
213
+ question = st.text_input("Ex.: Qual a principal conclusão do estudo?")
214
+ sec_list = ["(todas)"] + list(st.session_state.sections.keys())
215
+ sec_sel = st.selectbox("Escopo (seção)", sec_list, index=0)
216
+
217
+ if st.button("Responder", type="primary"):
218
+ if not st.session_state.index:
219
+ st.error("Processe o PDF primeiro.")
220
+ elif not question.strip():
221
+ st.error("Digite uma pergunta.")
222
+ else:
223
+ # LlamaIndex retriever: se filtrar por seção, pegue um pouco mais de candidatos
224
+ base_k = top_k * 3 if sec_sel != "(todas)" else top_k
225
+ li_retriever = st.session_state.index.as_retriever(similarity_top_k=base_k)
226
+
227
+ retriever = LlamaIndexRetriever(li_retriever=li_retriever, section_eq=None if sec_sel=="(todas)" else sec_sel)
228
+
229
+ # LLM + chains atuais (sem deprecations)
230
+ llm = ChatOllama(model="llama3.2:1b", temperature=0)
231
+ combine_docs_chain = create_stuff_documents_chain(llm, RAG_PROMPT)
232
+ rag_chain = create_retrieval_chain(retriever, combine_docs_chain)
233
+
234
+ with st.spinner("Consultando…"):
235
+ result = rag_chain.invoke({"input": question})
236
+
237
+ answer = result.get("answer", "Não sei.")
238
+ st.markdown("### Resposta")
239
+ st.write(answer)
240
+
241
+
242
+
243
+ st.caption("Fontes")
244
+ srcs = result.get("context") or [] # lista de Documents
245
+
246
+ if srcs:
247
+ for i, d in enumerate(srcs, 1):
248
+ src = d.metadata.get("source", st.session_state.source_name)
249
+ sec = d.metadata.get("section", "")
250
+ if sec:
251
+ st.markdown(f"- {i}. **{src}** — _{sec}_")
252
+ else:
253
+ st.markdown(f"- {i}. **{src}**")
254
+ else:
255
+ st.write("—")