Geoeasy commited on
Commit
768ee8b
·
verified ·
1 Parent(s): 5cde204

Upload 5 files

Browse files
Files changed (6) hide show
  1. .gitattributes +1 -0
  2. app.py +345 -0
  3. build_index.py +59 -0
  4. embeddings.pkl +3 -0
  5. faiss_index.faiss +3 -0
  6. requirements.txt +13 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ faiss_index.faiss filter=lfs diff=lfs merge=lfs -text
app.py ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---------- app.py ----------
2
+ # Dependências:
3
+ # pip install gradio faiss-cpu sentence-transformers openai
4
+
5
+ import os
6
+ from pathlib import Path
7
+ import pickle
8
+
9
+ import gradio as gr
10
+ import faiss
11
+ from sentence_transformers import SentenceTransformer
12
+ from openai import OpenAI
13
+
14
+ # ========= NVIDIA API =========
15
+ # Em local: defina NV_API_KEY ou NVIDIA_API_KEY no ambiente.
16
+ # Em Hugging Face Spaces: crie um "Repository secret" chamado NVIDIA_API_KEY.
17
+ NV_API_KEY = os.environ.get("NVIDIA_API_KEY") or os.environ.get("NV_API_KEY")
18
+ if not NV_API_KEY:
19
+ raise RuntimeError(
20
+ "A chave da NVIDIA não foi encontrada.\n"
21
+ "Defina um secret chamado 'NVIDIA_API_KEY' (ou NV_API_KEY) com a tua chave da NVIDIA.\n"
22
+ "• Localmente: export NVIDIA_API_KEY='SUA_CHAVE'\n"
23
+ "• Hugging Face Spaces: Settings -> Repository secrets -> Add secret."
24
+ )
25
+
26
+ client = OpenAI(
27
+ base_url="https://integrate.api.nvidia.com/v1",
28
+ api_key=NV_API_KEY,
29
+ )
30
+ CHAT_MODEL = "meta/llama3-8b-instruct"
31
+
32
+ # ========= Configuração do App =========
33
+ APP_TITLE = "EcoLexIA – Assistente Inteligente de Leis Ambientais de Portugal"
34
+
35
+ INTRO = (
36
+ "👋 Bem-vindo ao **EcoLexIA**, o teu assistente jurídico especializado em **direito do ambiente em Portugal**.\n\n"
37
+ "Este sistema utiliza **RAG (Retrieval-Augmented Generation)** para consultar automaticamente os documentos legais "
38
+ "carregados (leis, decretos, regulamentos, pareceres, etc.) e responder às tuas perguntas com base nesses textos."
39
+ )
40
+
41
+ SUGGESTION_QUESTIONS = [
42
+ "Resuma os principais princípios da Lei de Bases do Ambiente.",
43
+ "Quais são as obrigações do Estado em matéria de proteção ambiental?",
44
+ "Explique como funciona a Avaliação de Impacte Ambiental em Portugal.",
45
+ "Que legislação regula a gestão de resíduos urbanos?",
46
+ "Existe enquadramento legal para participação pública em decisões ambientais?",
47
+ "Quais são as regras sobre emissões poluentes na indústria?",
48
+ ]
49
+
50
+ SUGGESTIONS_THEMES = {
51
+ "Lei de Bases do Ambiente": [
52
+ "Quais são os princípios fundamentais da Lei de Bases do Ambiente?",
53
+ "Como a Lei de Bases do Ambiente enquadra o desenvolvimento sustentável?",
54
+ ],
55
+ "Avaliação de Impacte Ambiental (AIA)": [
56
+ "Explique o que é Avaliação de Impacte Ambiental e quando é obrigatória.",
57
+ "Que entidades estão envolvidas no processo de AIA?",
58
+ ],
59
+ "Resíduos & Poluição": [
60
+ "Que legislação trata da gestão de resíduos em Portugal?",
61
+ "Que obrigações têm as empresas relativamente ao controlo de emissões poluentes?",
62
+ ],
63
+ "Ordenamento do Território & Conservação": [
64
+ "Como o ordenamento do território se articula com a proteção ambiental?",
65
+ "Que diplomas legais regulam áreas protegidas e conservação da natureza?",
66
+ ],
67
+ }
68
+
69
+ # ========= Caminhos do índice =========
70
+ INDEX_FILE = "faiss_index.faiss"
71
+ EMBEDDINGS_FILE = "embeddings.pkl"
72
+
73
+ if not Path(INDEX_FILE).exists() or not Path(EMBEDDINGS_FILE).exists():
74
+ raise FileNotFoundError(
75
+ "❌ Índice não encontrado.\n"
76
+ "Certifique-se de que 'faiss_index.faiss' e 'embeddings.pkl' "
77
+ "foram gerados pelo build_index.py na mesma pasta deste app."
78
+ )
79
+
80
+ index = faiss.read_index(INDEX_FILE)
81
+ with open(EMBEDDINGS_FILE, "rb") as f:
82
+ emb_data = pickle.load(f)
83
+
84
+ texts = emb_data["texts"]
85
+ metadatas = emb_data["metadatas"]
86
+
87
+ # Mesmo modelo de embeddings usado no build_index.py
88
+ embedding_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
89
+
90
+ # Histórico do chat: lista de (user, assistant)
91
+ dialog_history = []
92
+
93
+
94
+ # ========= Recuperação de contexto =========
95
+ def retrieve_context(query: str, k: int = 4) -> str:
96
+ """Busca k trechos mais relevantes no índice FAISS para a pergunta."""
97
+ if not query or not query.strip():
98
+ return ""
99
+
100
+ q_emb = embedding_model.encode([query], convert_to_numpy=True)
101
+ _, indices = index.search(q_emb, k)
102
+
103
+ parts = []
104
+ for idx in indices[0]:
105
+ if idx < 0 or idx >= len(texts):
106
+ continue
107
+ chunk = texts[idx]
108
+ meta = metadatas[idx] if idx < len(metadatas) else {}
109
+ src = meta.get("source", "documento desconhecido")
110
+ parts.append(f"[Documento: {src}]\n{chunk}")
111
+
112
+ return "\n\n---\n\n".join(parts)
113
+
114
+
115
+ # ========= Streaming da NVIDIA =========
116
+ def nv_stream(messages, temperature: float, top_p: float, max_tokens: int):
117
+ """Stream da resposta da NVIDIA (LLaMA 3)."""
118
+ reply = ""
119
+ stream = client.chat.completions.create(
120
+ model=CHAT_MODEL,
121
+ messages=messages,
122
+ temperature=temperature,
123
+ top_p=top_p,
124
+ max_tokens=max_tokens,
125
+ stream=True,
126
+ )
127
+ for chunk in stream:
128
+ delta = chunk.choices[0].delta
129
+ if getattr(delta, "content", None):
130
+ reply += delta.content
131
+ yield reply
132
+
133
+
134
+ # ========= Lógica do chat =========
135
+ def chatbot(user_input: str, temperature: float, top_p: float, max_tokens: int):
136
+ global dialog_history
137
+
138
+ if not user_input or not user_input.strip():
139
+ return dialog_history, ""
140
+
141
+ context = retrieve_context(user_input, k=6)
142
+
143
+ system_msg = {
144
+ "role": "system",
145
+ "content": (
146
+ "És um assistente jurídico especializado em direito do ambiente em Portugal. "
147
+ "Responde SEMPRE em português europeu, de forma clara e estruturada.\n\n"
148
+ "Regras:\n"
149
+ "1. Usa apenas o contexto abaixo para responder.\n"
150
+ "2. Se não houver informação suficiente, diz que não encontras base nos documentos e "
151
+ "sugere consultar a legislação oficial.\n"
152
+ "3. Indica o nome do documento (PDF) sempre que fizer sentido.\n\n"
153
+ f"=== CONTEXTO RECUPERADO ===\n{context}\n\n"
154
+ ),
155
+ }
156
+
157
+ messages = [system_msg]
158
+ for u, a in dialog_history:
159
+ messages.append({"role": "user", "content": u})
160
+ messages.append({"role": "assistant", "content": a})
161
+ messages.append({"role": "user", "content": user_input})
162
+
163
+ reply_full = ""
164
+ try:
165
+ for partial in nv_stream(messages, temperature, top_p, max_tokens):
166
+ reply_full = partial
167
+ dialog_history.append((user_input, reply_full))
168
+ except Exception as e:
169
+ reply_full = f"⚠️ Erro na API NVIDIA: {type(e).__name__}: {e}"
170
+ dialog_history.append((user_input, reply_full))
171
+
172
+ return dialog_history, ""
173
+
174
+
175
+ def clear_history():
176
+ global dialog_history
177
+ dialog_history = []
178
+ return [], ""
179
+
180
+
181
+ # ========= CSS simples / layout padrão =========
182
+ custom_css = r"""
183
+ body, .gradio-container {
184
+ background: #ffffff;
185
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
186
+ }
187
+
188
+ /* Header azul */
189
+ #header-box {
190
+ max-width: 1100px;
191
+ margin: 1.5rem auto 1rem auto;
192
+ }
193
+
194
+ .header-card {
195
+ background: linear-gradient(135deg, #0b3c91 0%, #1565c0 40%, #1e88e5 100%);
196
+ border-radius: 16px;
197
+ padding: 1.4rem 1.8rem;
198
+ color: #ffffff;
199
+ box-shadow: 0 14px 30px rgba(15, 23, 42, 0.18);
200
+ }
201
+
202
+ .header-title {
203
+ font-size: 1.6rem;
204
+ font-weight: 700;
205
+ margin: 0;
206
+ color: #ffffff !important;
207
+ }
208
+
209
+ .header-subtitle {
210
+ margin: 0.35rem 0 0 0;
211
+ font-size: 0.96rem;
212
+ opacity: 0.95;
213
+ color: #ffffff !important;
214
+ }
215
+
216
+ /* Cartões principais */
217
+ .card {
218
+ background: #ffffff;
219
+ border-radius: 16px;
220
+ border: 1px solid #e0e0e0;
221
+ box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06);
222
+ padding: 1rem 1.1rem;
223
+ }
224
+
225
+ /* Chat */
226
+ #chat-window {
227
+ height: 60vh;
228
+ }
229
+
230
+ /* Botões */
231
+ .gr-button-primary {
232
+ background: #1565c0 !important;
233
+ color: #ffffff !important;
234
+ border: none !important;
235
+ }
236
+
237
+ .gr-button-secondary {
238
+ background: #f5f5f5 !important;
239
+ color: #333333 !important;
240
+ border: 1px solid #e0e0e0 !important;
241
+ }
242
+
243
+ /* Sugestões */
244
+ .suggestion-btn {
245
+ width: 100%;
246
+ justify-content: flex-start;
247
+ font-size: 0.88rem;
248
+ }
249
+
250
+ /* Rodapé */
251
+ .app-footer {
252
+ margin-top: 1rem;
253
+ font-size: 0.8rem;
254
+ text-align: center;
255
+ color: #555555;
256
+ }
257
+ """
258
+
259
+
260
+ # ========= Layout Gradio =========
261
+ with gr.Blocks(title=APP_TITLE, css=custom_css, theme=gr.themes.Soft()) as demo:
262
+ # Header
263
+ with gr.Group(elem_id="header-box"):
264
+ gr.HTML(
265
+ f"""
266
+ <div class="header-card">
267
+ <div class="header-title">{APP_TITLE}</div>
268
+ <div class="header-subtitle">
269
+ Consultor jurídico inteligente com RAG sobre legislação ambiental portuguesa.
270
+ </div>
271
+ </div>
272
+ """
273
+ )
274
+
275
+ gr.Markdown(INTRO)
276
+
277
+ with gr.Row():
278
+ # Coluna principal (chat)
279
+ with gr.Column(scale=3):
280
+ with gr.Group(elem_classes="card"):
281
+ gr.Markdown("### 💬 Conversa Jurídica")
282
+ chatbot_ui = gr.Chatbot(
283
+ type="tuples",
284
+ elem_id="chat-window",
285
+ label="Chatbot",
286
+ )
287
+
288
+ txt = gr.Textbox(
289
+ placeholder="Escreve aqui a tua pergunta sobre leis do ambiente em Portugal…",
290
+ lines=3,
291
+ show_label=False,
292
+ )
293
+
294
+ with gr.Row():
295
+ btn_send = gr.Button("Enviar", variant="primary")
296
+ btn_clear = gr.Button("Limpar", variant="secondary")
297
+
298
+ with gr.Accordion("Parâmetros avançados", open=False):
299
+ temperature = gr.Slider(0, 1, value=0.5, label="Temperature")
300
+ top_p = gr.Slider(0, 1, value=0.9, label="Top-p")
301
+ max_tokens = gr.Slider(64, 2048, value=512, step=64, label="Max Tokens")
302
+
303
+ btn_send.click(
304
+ chatbot,
305
+ [txt, temperature, top_p, max_tokens],
306
+ [chatbot_ui, txt],
307
+ )
308
+ txt.submit(
309
+ chatbot,
310
+ [txt, temperature, top_p, max_tokens],
311
+ [chatbot_ui, txt],
312
+ )
313
+ btn_clear.click(
314
+ clear_history,
315
+ [],
316
+ [chatbot_ui, txt],
317
+ )
318
+
319
+ # Sidebar
320
+ with gr.Column(scale=2):
321
+ with gr.Group(elem_classes="card"):
322
+ gr.Markdown("### 💡 Sugestões rápidas")
323
+ for q in SUGGESTION_QUESTIONS:
324
+ gr.Button(q, elem_classes="suggestion-btn").click(
325
+ lambda s=q: s, outputs=[txt]
326
+ )
327
+
328
+ gr.Markdown("---")
329
+ gr.Markdown("### 📚 Explorar por tema")
330
+
331
+ for theme, qs in SUGGESTIONS_THEMES.items():
332
+ with gr.Accordion(theme, open=False):
333
+ for q in qs:
334
+ gr.Button(q, elem_classes="suggestion-btn").click(
335
+ lambda s=q: s, outputs=[txt]
336
+ )
337
+
338
+ gr.Markdown(
339
+ '<div class="app-footer">EcoLexIA · Sistema RAG para legislação ambiental em Portugal</div>'
340
+ )
341
+
342
+ # Para Hugging Face Spaces basta que a variável `demo` exista;
343
+ # ainda assim manter o launch permite rodar localmente.
344
+ if __name__ == "__main__":
345
+ demo.launch()
build_index.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---------- build_index.py ----------
2
+ # Install dependencies:
3
+ # pip install faiss-cpu PyPDF2 sentence-transformers
4
+
5
+ import os
6
+ import faiss
7
+ import pickle
8
+ from PyPDF2 import PdfReader
9
+ from sentence_transformers import SentenceTransformer
10
+
11
+ # Parameters
12
+ doc_folder = "./pdfs" # folder containing PDF files
13
+ index_path = "faiss_index.faiss"
14
+ embeddings_path = "embeddings.pkl"
15
+ model_name = "sentence-transformers/all-MiniLM-L6-v2"
16
+ chunk_size = 500 # characters per chunk
17
+ chunk_overlap = 50 # characters overlap between chunks
18
+
19
+ # Load embedding model
20
+ model = SentenceTransformer(model_name)
21
+
22
+ # Utility: split text into chunks
23
+ def chunk_text(text, size=chunk_size, overlap=chunk_overlap):
24
+ chunks = []
25
+ start = 0
26
+ while start < len(text):
27
+ end = min(start + size, len(text))
28
+ chunks.append(text[start:end])
29
+ start += size - overlap
30
+ return chunks
31
+
32
+ # Iterate PDFs, extract and chunk text
33
+ texts = []
34
+ metadatas = []
35
+ for fname in os.listdir(doc_folder):
36
+ if not fname.lower().endswith('.pdf'): continue
37
+ path = os.path.join(doc_folder, fname)
38
+ reader = PdfReader(path)
39
+ full_text = ""
40
+ for page in reader.pages:
41
+ full_text += page.extract_text() or ""
42
+ for chunk in chunk_text(full_text):
43
+ texts.append(chunk)
44
+ metadatas.append({"source": fname})
45
+
46
+ # Create embeddings matrix
47
+ embeddings = model.encode(texts, show_progress_bar=True)
48
+
49
+ # Build FAISS index
50
+ dim = embeddings.shape[1]
51
+ index = faiss.IndexFlatL2(dim)
52
+ index.add(embeddings)
53
+
54
+ # Save index and metadata
55
+ faiss.write_index(index, index_path)
56
+ with open(embeddings_path, 'wb') as f:
57
+ pickle.dump({"texts": texts, "metadatas": metadatas}, f)
58
+
59
+ print(f"Index built with {len(texts)} chunks, saved to '{index_path}' and '{embeddings_path}'")
embeddings.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fc667e0c86f7460adad3bc64ef05bbc48cb6fb57c3451188b7ee6c7f484ce2b0
3
+ size 441180
faiss_index.faiss ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d9c0dc29834a5a2e9bac235165c041a261423b14a027ca91bfc90e0c02e3533d
3
+ size 1259565
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ numpy>=1.21.0
2
+ sentence-transformers>=2.2.2
3
+ faiss-cpu>=1.7.0
4
+ torch>=2.0.1
5
+ transformers>=4.30.2
6
+ accelerate>=0.20.3
7
+ gradio>=4.31
8
+ huggingface_hub>=0.15.1
9
+ bitsandbytes==0.43.1; platform_system!="Windows" and platform_machine=="x86_64"
10
+ openai
11
+ langchain
12
+ langchain-community
13
+ reportlab