|
|
|
|
|
|
|
|
|
|
|
|
|
|
import os |
|
|
from pathlib import Path |
|
|
import pickle |
|
|
from typing import List, Dict, Any |
|
|
|
|
|
import gradio as gr |
|
|
import faiss |
|
|
from sentence_transformers import SentenceTransformer |
|
|
from openai import OpenAI |
|
|
|
|
|
|
|
|
NV_API_KEY = os.environ.get("NVIDIA_API_KEY") or os.environ.get("NV_API_KEY") |
|
|
if not NV_API_KEY: |
|
|
raise RuntimeError( |
|
|
"A chave da NVIDIA não foi encontrada.\n" |
|
|
"Defina um secret chamado 'NVIDIA_API_KEY' (ou NV_API_KEY) com a tua chave da NVIDIA.\n" |
|
|
"• Localmente: export NVIDIA_API_KEY='SUA_CHAVE'\n" |
|
|
"• Hugging Face Spaces: Settings -> Repository secrets -> Add secret." |
|
|
) |
|
|
|
|
|
client = OpenAI( |
|
|
base_url="https://integrate.api.nvidia.com/v1", |
|
|
api_key=NV_API_KEY, |
|
|
) |
|
|
CHAT_MODEL = "meta/llama3-8b-instruct" |
|
|
|
|
|
|
|
|
APP_TITLE = "EcoLexIA – Assistente Inteligente de Leis Ambientais de Portugal" |
|
|
|
|
|
INTRO = ( |
|
|
"👋 Bem-vindo ao **EcoLexIA**, o teu assistente jurídico especializado em **direito do ambiente em Portugal**.\n\n" |
|
|
"Este sistema utiliza **RAG (Retrieval-Augmented Generation)** para consultar automaticamente os documentos legais " |
|
|
"carregados (leis, decretos, regulamentos, pareceres, etc.) e responder às tuas perguntas com base nesses textos." |
|
|
) |
|
|
|
|
|
SUGGESTION_QUESTIONS = [ |
|
|
"Resuma os principais princípios da Lei de Bases do Ambiente.", |
|
|
"Quais são as obrigações do Estado em matéria de proteção ambiental?", |
|
|
"Explique como funciona a Avaliação de Impacte Ambiental em Portugal.", |
|
|
"Que legislação regula a gestão de resíduos urbanos?", |
|
|
"Existe enquadramento legal para participação pública em decisões ambientais?", |
|
|
"Quais são as regras sobre emissões poluentes na indústria?", |
|
|
] |
|
|
|
|
|
SUGGESTIONS_THEMES = { |
|
|
"Lei de Bases do Ambiente": [ |
|
|
"Quais são os princípios fundamentais da Lei de Bases do Ambiente?", |
|
|
"Como a Lei de Bases do Ambiente enquadra o desenvolvimento sustentável?", |
|
|
], |
|
|
"Avaliação de Impacte Ambiental (AIA)": [ |
|
|
"Explique o que é Avaliação de Impacte Ambiental e quando é obrigatória.", |
|
|
"Que entidades estão envolvidas no processo de AIA?", |
|
|
], |
|
|
"Resíduos & Poluição": [ |
|
|
"Que legislação trata da gestão de resíduos em Portugal?", |
|
|
"Que obrigações têm as empresas relativamente ao controlo de emissões poluentes?", |
|
|
], |
|
|
"Ordenamento do Território & Conservação": [ |
|
|
"Como o ordenamento do território se articula com a proteção ambiental?", |
|
|
"Que diplomas legais regulam áreas protegidas e conservação da natureza?", |
|
|
], |
|
|
} |
|
|
|
|
|
|
|
|
INDEX_FILE = "faiss_index.faiss" |
|
|
EMBEDDINGS_FILE = "embeddings.pkl" |
|
|
|
|
|
if not Path(INDEX_FILE).exists() or not Path(EMBEDDINGS_FILE).exists(): |
|
|
raise FileNotFoundError( |
|
|
"❌ Índice não encontrado.\n" |
|
|
"Certifique-se de que 'faiss_index.faiss' e 'embeddings.pkl' " |
|
|
"foram gerados pelo build_index.py na mesma pasta deste app." |
|
|
) |
|
|
|
|
|
index = faiss.read_index(INDEX_FILE) |
|
|
with open(EMBEDDINGS_FILE, "rb") as f: |
|
|
emb_data = pickle.load(f) |
|
|
|
|
|
texts = emb_data["texts"] |
|
|
metadatas = emb_data["metadatas"] |
|
|
|
|
|
|
|
|
embedding_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2") |
|
|
|
|
|
|
|
|
|
|
|
dialog_history: List[Dict[str, str]] = [] |
|
|
|
|
|
|
|
|
|
|
|
def retrieve_context(query: str, k: int = 4) -> str: |
|
|
if not query or not query.strip(): |
|
|
return "" |
|
|
|
|
|
q_emb = embedding_model.encode([query], convert_to_numpy=True) |
|
|
_, indices = index.search(q_emb, k) |
|
|
|
|
|
parts = [] |
|
|
for idx in indices[0]: |
|
|
if idx < 0 or idx >= len(texts): |
|
|
continue |
|
|
chunk = texts[idx] |
|
|
meta = metadatas[idx] if idx < len(metadatas) else {} |
|
|
src = meta.get("source", "documento desconhecido") |
|
|
parts.append(f"[Documento: {src}]\n{chunk}") |
|
|
|
|
|
return "\n\n---\n\n".join(parts) |
|
|
|
|
|
|
|
|
|
|
|
def nv_stream(messages: List[Dict[str, str]], temperature: float, top_p: float, max_tokens: int): |
|
|
reply = "" |
|
|
stream = client.chat.completions.create( |
|
|
model=CHAT_MODEL, |
|
|
messages=messages, |
|
|
temperature=temperature, |
|
|
top_p=top_p, |
|
|
max_tokens=max_tokens, |
|
|
stream=True, |
|
|
) |
|
|
for chunk in stream: |
|
|
delta = chunk.choices[0].delta |
|
|
if getattr(delta, "content", None): |
|
|
reply += delta.content |
|
|
yield reply |
|
|
|
|
|
|
|
|
|
|
|
def chatbot(user_input: str, temperature: float, top_p: float, max_tokens: int): |
|
|
global dialog_history |
|
|
|
|
|
if not user_input or not user_input.strip(): |
|
|
return dialog_history, "" |
|
|
|
|
|
context = retrieve_context(user_input, k=6) |
|
|
|
|
|
system_msg = { |
|
|
"role": "system", |
|
|
"content": ( |
|
|
"És um assistente jurídico especializado em direito do ambiente em Portugal. " |
|
|
"Responde SEMPRE em português europeu, de forma clara e estruturada.\n\n" |
|
|
"Regras:\n" |
|
|
"1. Usa apenas o contexto abaixo para responder.\n" |
|
|
"2. Se não houver informação suficiente, diz que não encontras base nos documentos e " |
|
|
"sugere consultar a legislação oficial.\n" |
|
|
"3. Indica o nome do documento (PDF) sempre que fizer sentido.\n\n" |
|
|
f"=== CONTEXTO RECUPERADO ===\n{context}\n\n" |
|
|
), |
|
|
} |
|
|
|
|
|
|
|
|
messages: List[Dict[str, str]] = [system_msg] + dialog_history + [{"role": "user", "content": user_input}] |
|
|
|
|
|
reply_full = "" |
|
|
try: |
|
|
for partial in nv_stream(messages, temperature, top_p, max_tokens): |
|
|
reply_full = partial |
|
|
|
|
|
|
|
|
dialog_history = dialog_history + [ |
|
|
{"role": "user", "content": user_input}, |
|
|
{"role": "assistant", "content": reply_full}, |
|
|
] |
|
|
except Exception as e: |
|
|
reply_full = f"⚠️ Erro na API NVIDIA: {type(e).__name__}: {e}" |
|
|
dialog_history = dialog_history + [ |
|
|
{"role": "user", "content": user_input}, |
|
|
{"role": "assistant", "content": reply_full}, |
|
|
] |
|
|
|
|
|
return dialog_history, "" |
|
|
|
|
|
|
|
|
def clear_history(): |
|
|
global dialog_history |
|
|
dialog_history = [] |
|
|
return [], "" |
|
|
|
|
|
|
|
|
|
|
|
custom_css = r""" |
|
|
body, .gradio-container { |
|
|
background: #ffffff; |
|
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
|
|
} |
|
|
|
|
|
/* Header azul */ |
|
|
#header-box { |
|
|
max-width: 1100px; |
|
|
margin: 1.5rem auto 1rem auto; |
|
|
} |
|
|
|
|
|
.header-card { |
|
|
background: linear-gradient(135deg, #0b3c91 0%, #1565c0 40%, #1e88e5 100%); |
|
|
border-radius: 16px; |
|
|
padding: 1.4rem 1.8rem; |
|
|
color: #ffffff; |
|
|
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.18); |
|
|
} |
|
|
|
|
|
.header-title { |
|
|
font-size: 1.6rem; |
|
|
font-weight: 700; |
|
|
margin: 0; |
|
|
color: #ffffff !important; |
|
|
} |
|
|
|
|
|
.header-subtitle { |
|
|
margin: 0.35rem 0 0 0; |
|
|
font-size: 0.96rem; |
|
|
opacity: 0.95; |
|
|
color: #ffffff !important; |
|
|
} |
|
|
|
|
|
/* Cartões principais */ |
|
|
.card { |
|
|
background: #ffffff; |
|
|
border-radius: 16px; |
|
|
border: 1px solid #e0e0e0; |
|
|
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06); |
|
|
padding: 1rem 1.1rem; |
|
|
} |
|
|
|
|
|
/* Chat */ |
|
|
#chat-window { |
|
|
height: 60vh; |
|
|
} |
|
|
|
|
|
/* Botões */ |
|
|
.gr-button-primary { |
|
|
background: #1565c0 !important; |
|
|
color: #ffffff !important; |
|
|
border: none !important; |
|
|
} |
|
|
|
|
|
.gr-button-secondary { |
|
|
background: #f5f5f5 !important; |
|
|
color: #333333 !important; |
|
|
border: 1px solid #e0e0e0 !important; |
|
|
} |
|
|
|
|
|
/* Sugestões */ |
|
|
.suggestion-btn { |
|
|
width: 100%; |
|
|
justify-content: flex-start; |
|
|
font-size: 0.88rem; |
|
|
} |
|
|
|
|
|
/* Rodapé */ |
|
|
.app-footer { |
|
|
margin-top: 1rem; |
|
|
font-size: 0.8rem; |
|
|
text-align: center; |
|
|
color: #555555; |
|
|
} |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
with gr.Blocks(title=APP_TITLE) as demo: |
|
|
with gr.Group(elem_id="header-box"): |
|
|
gr.HTML( |
|
|
f""" |
|
|
<div class="header-card"> |
|
|
<div class="header-title">{APP_TITLE}</div> |
|
|
<div class="header-subtitle"> |
|
|
Consultor jurídico inteligente com RAG sobre legislação ambiental portuguesa. |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
) |
|
|
|
|
|
gr.Markdown(INTRO) |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(scale=3): |
|
|
with gr.Group(elem_classes="card"): |
|
|
gr.Markdown("### 💬 Conversa Jurídica") |
|
|
|
|
|
|
|
|
chatbot_ui = gr.Chatbot( |
|
|
elem_id="chat-window", |
|
|
label="Chatbot", |
|
|
) |
|
|
|
|
|
txt = gr.Textbox( |
|
|
placeholder="Escreve aqui a tua pergunta sobre leis do ambiente em Portugal…", |
|
|
lines=3, |
|
|
show_label=False, |
|
|
) |
|
|
|
|
|
with gr.Row(): |
|
|
btn_send = gr.Button("Enviar", variant="primary") |
|
|
btn_clear = gr.Button("Limpar", variant="secondary") |
|
|
|
|
|
with gr.Accordion("Parâmetros avançados", open=False): |
|
|
temperature = gr.Slider(0, 1, value=0.5, label="Temperature") |
|
|
top_p = gr.Slider(0, 1, value=0.9, label="Top-p") |
|
|
max_tokens = gr.Slider(64, 2048, value=512, step=64, label="Max Tokens") |
|
|
|
|
|
btn_send.click(chatbot, [txt, temperature, top_p, max_tokens], [chatbot_ui, txt]) |
|
|
txt.submit(chatbot, [txt, temperature, top_p, max_tokens], [chatbot_ui, txt]) |
|
|
btn_clear.click(clear_history, [], [chatbot_ui, txt]) |
|
|
|
|
|
with gr.Column(scale=2): |
|
|
with gr.Group(elem_classes="card"): |
|
|
gr.Markdown("### 💡 Sugestões rápidas") |
|
|
for q in SUGGESTION_QUESTIONS: |
|
|
gr.Button(q, elem_classes="suggestion-btn").click(lambda s=q: s, outputs=[txt]) |
|
|
|
|
|
gr.Markdown("---") |
|
|
gr.Markdown("### 📚 Explorar por tema") |
|
|
|
|
|
for theme, qs in SUGGESTIONS_THEMES.items(): |
|
|
with gr.Accordion(theme, open=False): |
|
|
for q in qs: |
|
|
gr.Button(q, elem_classes="suggestion-btn").click(lambda s=q: s, outputs=[txt]) |
|
|
|
|
|
gr.Markdown('<div class="app-footer">EcoLexIA · Sistema RAG para legislação ambiental em Portugal</div>') |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
demo.queue() |
|
|
demo.launch( |
|
|
theme=gr.themes.Soft(), |
|
|
css=custom_css, |
|
|
ssr_mode=False, |
|
|
) |
|
|
|
|
|
|
|
|
|