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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +391 -345
app.py CHANGED
@@ -1,345 +1,391 @@
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()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.get("texts", [])
85
+ metadatas = emb_data.get("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
+ # Proteções básicas
101
+ if index.ntotal == 0 or not texts:
102
+ return ""
103
+
104
+ q_emb = embedding_model.encode([query], convert_to_numpy=True)
105
+ _, indices = index.search(q_emb, k)
106
+
107
+ parts = []
108
+ for idx in indices[0]:
109
+ if idx < 0 or idx >= len(texts):
110
+ continue
111
+ chunk = texts[idx]
112
+ meta = metadatas[idx] if idx < len(metadatas) else {}
113
+ src = meta.get("source", "documento desconhecido")
114
+ parts.append(f"[Documento: {src}]\n{chunk}")
115
+
116
+ return "\n\n---\n\n".join(parts)
117
+
118
+
119
+ # ========= Streaming da NVIDIA (ROBUSTO) =========
120
+ def nv_stream(messages, temperature: float, top_p: float, max_tokens: int):
121
+ """
122
+ Stream da resposta da NVIDIA (LLaMA 3) com proteções:
123
+ - chunks podem vir sem `choices` (ou choices vazia)
124
+ - `delta` pode não existir
125
+ - `delta.content` pode ser None
126
+ """
127
+ reply = ""
128
+
129
+ stream = client.chat.completions.create(
130
+ model=CHAT_MODEL,
131
+ messages=messages,
132
+ temperature=temperature,
133
+ top_p=top_p,
134
+ max_tokens=max_tokens,
135
+ stream=True,
136
+ )
137
+
138
+ for chunk in stream:
139
+ choices = getattr(chunk, "choices", None)
140
+ if not choices:
141
+ continue
142
+
143
+ choice0 = choices[0]
144
+ delta = getattr(choice0, "delta", None)
145
+ if not delta:
146
+ continue
147
+
148
+ content = getattr(delta, "content", None)
149
+ if content:
150
+ reply += content
151
+ yield reply
152
+
153
+
154
+ # ========= Lógica do chat =========
155
+ def chatbot(user_input: str, temperature: float, top_p: float, max_tokens: int):
156
+ global dialog_history
157
+
158
+ if not user_input or not user_input.strip():
159
+ return dialog_history, ""
160
+
161
+ context = retrieve_context(user_input, k=6)
162
+
163
+ # Se não houver contexto, não deixa o modelo inventar
164
+ if not context.strip():
165
+ reply_full = (
166
+ "Não encontrei base suficiente nos documentos carregados para responder com segurança.\n\n"
167
+ "Sugestões:\n"
168
+ "• Verifica se os PDFs/leis foram realmente indexados.\n"
169
+ " Faz uma pergunta mais específica (ex.: diploma/ano/artigo).\n"
170
+ "• Consulta a legislação oficial (Diário da República Eletrónico).\n"
171
+ )
172
+ dialog_history.append((user_input, reply_full))
173
+ return dialog_history, ""
174
+
175
+ system_msg = {
176
+ "role": "system",
177
+ "content": (
178
+ "És um assistente jurídico especializado em direito do ambiente em Portugal. "
179
+ "Responde SEMPRE em português europeu, de forma clara e estruturada.\n\n"
180
+ "Regras:\n"
181
+ "1. Usa apenas o contexto abaixo para responder.\n"
182
+ "2. Se não houver informação suficiente, diz que não encontras base nos documentos e "
183
+ "sugere consultar a legislação oficial.\n"
184
+ "3. Indica o nome do documento (PDF) sempre que fizer sentido.\n\n"
185
+ f"=== CONTEXTO RECUPERADO ===\n{context}\n\n"
186
+ ),
187
+ }
188
+
189
+ messages = [system_msg]
190
+
191
+ # Reconstroi histórico (limita para evitar prompt gigante)
192
+ MAX_TURNS = 8 # últimas 8 interações
193
+ for u, a in dialog_history[-MAX_TURNS:]:
194
+ messages.append({"role": "user", "content": u})
195
+ messages.append({"role": "assistant", "content": a})
196
+
197
+ messages.append({"role": "user", "content": user_input})
198
+
199
+ reply_full = ""
200
+ try:
201
+ for partial in nv_stream(messages, temperature, top_p, max_tokens):
202
+ reply_full = partial
203
+
204
+ # ✅ Se o stream não devolveu conteúdo, evita choices[0] e afins
205
+ if not reply_full.strip():
206
+ reply_full = (
207
+ "⚠️ A API devolveu uma resposta vazia (sem conteúdo). "
208
+ "Isto pode acontecer por limite de contexto, rate limit, ou erro de input.\n"
209
+ "Tenta reduzir o tamanho dos trechos recuperados (k) ou o histórico."
210
+ )
211
+
212
+ dialog_history.append((user_input, reply_full))
213
+
214
+ except Exception as e:
215
+ reply_full = f"⚠️ Erro na API NVIDIA: {type(e).__name__}: {e}"
216
+ dialog_history.append((user_input, reply_full))
217
+
218
+ return dialog_history, ""
219
+
220
+
221
+ def clear_history():
222
+ global dialog_history
223
+ dialog_history = []
224
+ return [], ""
225
+
226
+
227
+ # ========= CSS simples / layout padrão =========
228
+ custom_css = r"""
229
+ body, .gradio-container {
230
+ background: #ffffff;
231
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
232
+ }
233
+
234
+ /* Header azul */
235
+ #header-box {
236
+ max-width: 1100px;
237
+ margin: 1.5rem auto 1rem auto;
238
+ }
239
+
240
+ .header-card {
241
+ background: linear-gradient(135deg, #0b3c91 0%, #1565c0 40%, #1e88e5 100%);
242
+ border-radius: 16px;
243
+ padding: 1.4rem 1.8rem;
244
+ color: #ffffff;
245
+ box-shadow: 0 14px 30px rgba(15, 23, 42, 0.18);
246
+ }
247
+
248
+ .header-title {
249
+ font-size: 1.6rem;
250
+ font-weight: 700;
251
+ margin: 0;
252
+ color: #ffffff !important;
253
+ }
254
+
255
+ .header-subtitle {
256
+ margin: 0.35rem 0 0 0;
257
+ font-size: 0.96rem;
258
+ opacity: 0.95;
259
+ color: #ffffff !important;
260
+ }
261
+
262
+ /* Cartões principais */
263
+ .card {
264
+ background: #ffffff;
265
+ border-radius: 16px;
266
+ border: 1px solid #e0e0e0;
267
+ box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06);
268
+ padding: 1rem 1.1rem;
269
+ }
270
+
271
+ /* Chat */
272
+ #chat-window {
273
+ height: 60vh;
274
+ }
275
+
276
+ /* Botões */
277
+ .gr-button-primary {
278
+ background: #1565c0 !important;
279
+ color: #ffffff !important;
280
+ border: none !important;
281
+ }
282
+
283
+ .gr-button-secondary {
284
+ background: #f5f5f5 !important;
285
+ color: #333333 !important;
286
+ border: 1px solid #e0e0e0 !important;
287
+ }
288
+
289
+ /* Sugestões */
290
+ .suggestion-btn {
291
+ width: 100%;
292
+ justify-content: flex-start;
293
+ font-size: 0.88rem;
294
+ }
295
+
296
+ /* Rodapé */
297
+ .app-footer {
298
+ margin-top: 1rem;
299
+ font-size: 0.8rem;
300
+ text-align: center;
301
+ color: #555555;
302
+ }
303
+ """
304
+
305
+
306
+ # ========= Layout Gradio =========
307
+ with gr.Blocks(title=APP_TITLE, css=custom_css, theme=gr.themes.Soft()) as demo:
308
+ # Header
309
+ with gr.Group(elem_id="header-box"):
310
+ gr.HTML(
311
+ f"""
312
+ <div class="header-card">
313
+ <div class="header-title">{APP_TITLE}</div>
314
+ <div class="header-subtitle">
315
+ Consultor jurídico inteligente com RAG sobre legislação ambiental portuguesa.
316
+ </div>
317
+ </div>
318
+ """
319
+ )
320
+
321
+ gr.Markdown(INTRO)
322
+
323
+ with gr.Row():
324
+ # Coluna principal (chat)
325
+ with gr.Column(scale=3):
326
+ with gr.Group(elem_classes="card"):
327
+ gr.Markdown("### 💬 Conversa Jurídica")
328
+ chatbot_ui = gr.Chatbot(
329
+ type="tuples",
330
+ elem_id="chat-window",
331
+ label="Chatbot",
332
+ )
333
+
334
+ txt = gr.Textbox(
335
+ placeholder="Escreve aqui a tua pergunta sobre leis do ambiente em Portugal…",
336
+ lines=3,
337
+ show_label=False,
338
+ )
339
+
340
+ with gr.Row():
341
+ btn_send = gr.Button("Enviar", variant="primary")
342
+ btn_clear = gr.Button("Limpar", variant="secondary")
343
+
344
+ with gr.Accordion("Parâmetros avançados", open=False):
345
+ temperature = gr.Slider(0, 1, value=0.5, label="Temperature")
346
+ top_p = gr.Slider(0, 1, value=0.9, label="Top-p")
347
+ max_tokens = gr.Slider(64, 2048, value=512, step=64, label="Max Tokens")
348
+
349
+ btn_send.click(
350
+ chatbot,
351
+ [txt, temperature, top_p, max_tokens],
352
+ [chatbot_ui, txt],
353
+ )
354
+ txt.submit(
355
+ chatbot,
356
+ [txt, temperature, top_p, max_tokens],
357
+ [chatbot_ui, txt],
358
+ )
359
+ btn_clear.click(
360
+ clear_history,
361
+ [],
362
+ [chatbot_ui, txt],
363
+ )
364
+
365
+ # Sidebar
366
+ with gr.Column(scale=2):
367
+ with gr.Group(elem_classes="card"):
368
+ gr.Markdown("### 💡 Sugestões rápidas")
369
+ for q in SUGGESTION_QUESTIONS:
370
+ gr.Button(q, elem_classes="suggestion-btn").click(
371
+ lambda s=q: s, outputs=[txt]
372
+ )
373
+
374
+ gr.Markdown("---")
375
+ gr.Markdown("### 📚 Explorar por tema")
376
+
377
+ for theme, qs in SUGGESTIONS_THEMES.items():
378
+ with gr.Accordion(theme, open=False):
379
+ for q in qs:
380
+ gr.Button(q, elem_classes="suggestion-btn").click(
381
+ lambda s=q: s, outputs=[txt]
382
+ )
383
+
384
+ gr.Markdown(
385
+ '<div class="app-footer">EcoLexIA · Sistema RAG para legislação ambiental em Portugal</div>'
386
+ )
387
+
388
+ # Para Hugging Face Spaces basta que a variável `demo` exista;
389
+ # ainda assim manter o launch permite rodar localmente.
390
+ if __name__ == "__main__":
391
+ demo.launch()