Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules/
.venv/
__pycache__/
*.pyc
.env
.DS_Store
*.log
16 changes: 9 additions & 7 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,28 @@

class Settings(BaseSettings):
# ── Provider switches ────────────────────────────────
llm_provider: str = "api" # "api" | "local"
emb_provider: str = "api" # "api" | "local" (reservado para Fase 2)
llm_provider: str = "local" # "api" | "local"
# emb_provider: str = "api" # "api" | "local" (reservado para Fase 2)

# ── API provider credentials ─────────────────────────
anthropic_api_key: str = ""
openai_api_key: str = ""
gemini_api_key: str = ""
llm_api_model: str = "claude-sonnet-4-20250514" # troque conforme o provider
llm_api_model: str = "mitral-7b" # use sabiá se disponível; caso contrário, mantenha o provider padrão

# ── Local provider (Ollama / vLLM) ───────────────────
ollama_base_url: str = "http://localhost:11434"
ollama_model: str = "llama3"
ollama_model: str = "mitral-7b" # troque para "sabiá" se seu provider usar acento
vllm_base_url: str = "http://localhost:8000"
vllm_model: str = "mistral-7b-instruct"
vllm_model: str = "sabia-7b" # ajustar para seu modelo local

# ── PubMed ────────────────────────────────────────────
pubmed_max_results: int = 6
# ── Dataset ────────────────────────────────────────────
dataset_max_results: int = 6
enable_dataset: bool = False

class Config:
env_file = ".env"
extra = "ignore"

@lru_cache
def get_settings() -> Settings:
Expand Down
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

app = FastAPI(
title="MenopausIA API",
description="API agnética para perguntas sobre menopausa baseadas em evidências (PubMed).",
description="API agnética para perguntas sobre menopausa baseadas em evidências (Dataset Hugging Face).",
version="1.0.0",
)

Expand Down
2 changes: 1 addition & 1 deletion backend/app/providers/provider_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ async def _gemini(self, system: str, user: str) -> str:
)
async with httpx.AsyncClient(timeout=60) as client:
r = await client.post(url, json={
"system_instruction": {"parts": [{"text": system}]},
"systemInstruction": {"parts": [{"text": system}]},
"contents": [{"parts": [{"text": user}]}],
})
r.raise_for_status()
Expand Down
25 changes: 24 additions & 1 deletion backend/app/providers/provider_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ async def complete(self, system: str, user: str) -> str:
return await self._vllm(system, user)

async def _ollama(self, system: str, user: str) -> str:
print(f"[OLLAMA] Chamando Ollama em {settings.ollama_base_url}/api/chat com modelo {settings.ollama_model}")
async with httpx.AsyncClient(timeout=120) as client:
r = await client.post(
f"{settings.ollama_base_url}/api/chat",
Expand All @@ -30,8 +31,30 @@ async def _ollama(self, system: str, user: str) -> str:
],
},
)
print(f"[OLLAMA] Status da resposta: {r.status_code}")
r.raise_for_status()
return r.json()["message"]["content"]
response_json = r.json()

# Compatibilidade com formatos de resposta Ollama / modelos locais.
content = ""
if isinstance(response_json, dict):
# Ollama 1.x/2.x: { "message": { "content": "..." } }
message = response_json.get("message")
if isinstance(message, dict):
content = message.get("content", "")
# Possível fallback: { "choices": [{ "message": { "content": "..." }}] }
if not content:
choices = response_json.get("choices")
if isinstance(choices, list) and choices:
msg = choices[0].get("message") if isinstance(choices[0], dict) else None
if isinstance(msg, dict):
content = msg.get("content", "")

if not isinstance(content, str):
content = str(content)

print(f"[OLLAMA] Comprimento do conteúdo da resposta: {len(content)}")
return content

async def _vllm(self, system: str, user: str) -> str:
async with httpx.AsyncClient(timeout=120) as client:
Expand Down
32 changes: 15 additions & 17 deletions backend/app/routers/ask.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,8 @@ class AskRequest(BaseModel):
question: str
plain_language: bool = False

class ArticleOut(BaseModel):
pmid: str
title: str
year: str
journal: str

class AskResponse(BaseModel):
answer: str
pubmed_query: str
articles: list[ArticleOut]
answer: str

@router.post("/ask", response_model=AskResponse)
async def ask(body: AskRequest):
Expand All @@ -30,16 +22,12 @@ async def ask(body: AskRequest):
)
return AskResponse(
answer=result.answer,
pubmed_query=result.pubmed_query,
articles=[
ArticleOut(pmid=a.pmid, title=a.title, year=a.year, journal=a.journal)
for a in result.articles
],
)

@router.post("/ask/stream")
async def ask_stream(body: AskRequest):
"""Endpoint com SSE — envia eventos de progresso + resposta final."""
print(f"[ASK/STREAM] Recebida solicitação para pergunta: {body.question}")
async def event_generator():
steps = []

Expand All @@ -56,6 +44,7 @@ async def collect_step(msg: str):
# Envia etapas em tempo real via SSE
gen = _stream_pipeline(body, on_step=collect_step)
async for event in gen:
print(f"[ASK/STREAM] Enviando evento: {event.strip()}")
yield event

return StreamingResponse(event_generator(), media_type="text/event-stream")
Expand All @@ -64,28 +53,37 @@ async def _stream_pipeline(body: AskRequest, on_step):
"""Executa o pipeline e emite eventos SSE."""
import asyncio

print("[STREAM_PIPELINE] Iniciando pipeline de stream")
step_queue: asyncio.Queue = asyncio.Queue()

async def enqueue_step(msg: str):
await step_queue.put(("step", msg))

async def run():
print("[STREAM_PIPELINE] Executando tarefa do pipeline")
result = await run_pipeline(
question=body.question,
plain_language=body.plain_language,
on_step=enqueue_step,
)
print("[STREAM_PIPELINE] Pipeline concluído")
await step_queue.put(("done", result))

task = asyncio.create_task(run())

while True:
kind, payload = await step_queue.get()
print(f"[STREAM_PIPELINE] Recebido da fila: {kind}")
if kind == "step":
yield f"data: {json.dumps({'type': 'step', 'message': payload})}\n\n"
event = f"data: {json.dumps({'type': 'step', 'message': payload})}\n\n"
print(f"[STREAM_PIPELINE] Enviando evento de etapa: {event.strip()}")
yield event
elif kind == "done":
result = payload
yield f"data: {json.dumps({'type': 'result', 'answer': result.answer, 'pubmed_query': result.pubmed_query, 'articles': [{'pmid': a.pmid, 'title': a.title, 'year': a.year, 'journal': a.journal} for a in result.articles]})}\n\n"
event = f"data: {json.dumps({'type': 'result', 'answer': result.answer})}\n\n"
print(f"[STREAM_PIPELINE] Enviando evento de resultado, comprimento da resposta: {len(result.answer)}")
yield event
break

await task
await task
print("[STREAM_PIPELINE] Pipeline de stream finalizado")
148 changes: 95 additions & 53 deletions backend/app/services/pipeline.py
Original file line number Diff line number Diff line change
@@ -1,81 +1,123 @@
from dataclasses import dataclass, field
from typing import List, Callable, Awaitable
from app.services.pubmed import search_pubmed, fetch_abstracts, Article
from dataclasses import dataclass
import re
from typing import Callable, Awaitable
from app.providers import get_llm_provider
from filter_dataset import load_health_female_dataset
import unidecode

StepCallback = Callable[[str], Awaitable[None]] | None

print("[PIPELINE] Carregando e filtrando dataset do Hugging Face...")
dataset = load_health_female_dataset()
print(f"[PIPELINE] Dataset carregado: {len(dataset)} itens")


def normalize_text(text: str) -> str:
return unidecode.unidecode(str(text)).lower()


def select_best_dataset_item(question: str, keywords: list[str]) -> tuple[dict | None, int]:
best_item = None
best_score = -1
question_norm = normalize_text(question)

for item in dataset:
text = normalize_text(f"{item['input']} {item['output']}")
score = 0

for keyword in keywords:
keyword_norm = normalize_text(keyword)
if keyword_norm and keyword_norm in text:
score += 1

if question_norm in text:
score += 2

if score > best_score:
best_score = score
best_item = item

return best_item, best_score


async def extract_search_keywords(llm, question: str) -> list[str]:
system = (
"Você é um assistente médico que transforma perguntas em consultas de busca para encontrar respostas em um dataset de perguntas e respostas médicas. "
"Responda apenas com palavras-chave ou expressões curtas, separadas por vírgula."
)
user = (
f"Pergunta: {question}\n\n"
"Extraia até 8 palavras-chave ou expressões curtas que ajudem a procurar a resposta correta em um dataset de saúde feminina. "
"Use termos específicos de sintomas, condições, tratamentos e palavras-chave médicas relevantes."
)
response = await llm.complete(system=system, user=user)
raw_keywords = [part.strip() for part in re.split(r"[,;\n]+", response) if part.strip()]
return raw_keywords or [question]

async def build_answer_from_dataset(llm, question: str, item: dict, plain_language: bool) -> str:
system = (
"Você é um assistente de saúde que responde perguntas de usuários com base em um exemplo encontrado em um dataset médico. "
"Use apenas a informação fornecida no dataset como fonte principal, e explique-a de forma clara e objetiva."
)
style = (
"Responda em linguagem simples e acessível, usando frases curtas e exemplos claros."
if plain_language
else "Responda de forma natural, profissional e direta ao ponto."
)
user = (
f"Pergunta do usuário: {question}\n\n"
"Exemplo relevante encontrado no dataset:\n"
f"- Pergunta do dataset: {item['input']}\n"
f"- Resposta do dataset: {item['output']}\n\n"
"Com base nesse exemplo, responda à pergunta do usuário. "
"Explique claramente como a resposta do dataset se relaciona com a dúvida atual. "
"Se o dataset não cobrir totalmente a pergunta, diga que a resposta é baseada no exemplo encontrado e mantenha a informação fiel.\n"
f"{style}"
)
response = await llm.complete(system=system, user=user)
return response.strip()

@dataclass
class PipelineResult:
answer: str
pubmed_query: str
articles: List[Article] = field(default_factory=list)
answer: str

async def run_pipeline(
question: str,
plain_language: bool = False,
on_step: StepCallback = None,
) -> PipelineResult:
print(f"[PIPELINE] Iniciando pipeline para pergunta: {question}")
llm = get_llm_provider()
print(f"[PIPELINE] Provedor LLM: {type(llm).__name__}")

async def step(msg: str):
print(f"[PIPELINE] Etapa: {msg}")
if on_step:
await on_step(msg)

# ── Etapa 1: traduzir pergunta → query PubMed ────────
await step("Traduzindo pergunta para query científica…")
pubmed_query = await llm.complete(
system="You are a biomedical search expert. Reply with only a concise PubMed English search query (max 8 keywords). No explanation, no quotes.",
user=f'Translate this menopause question to a PubMed search query: "{question}"',
)
pubmed_query = pubmed_query.strip().strip('"')

# ── Etapa 2: buscar PMIDs ────────────────────────────
await step(f"Buscando no PubMed: "{pubmed_query}"…")
ids = await search_pubmed(pubmed_query)

if not ids:
if not dataset:
return PipelineResult(
answer="Não encontrei artigos científicos relevantes no PubMed para essa pergunta. Tente reformular.",
pubmed_query=pubmed_query,
answer="Dataset não encontrado. Verifique se a conexão com o Hugging Face está funcionando.",
)

# ── Etapa 3: buscar abstracts ────────────────────────
await step(f"Recuperando {len(ids)} artigo(s) científico(s)…")
articles = await fetch_abstracts(ids)
await step("Interpretando a pergunta com o modelo...")
keywords = await extract_search_keywords(llm, question)
print(f"[PIPELINE] Keywords extraídas: {keywords}")

await step("Buscando resposta no dataset de saúde feminina…")
best_item, best_score = select_best_dataset_item(question, keywords)
print(f"[PIPELINE] Melhor item encontrado: score={best_score}")

if not articles:
if not best_item or best_score <= 0:
return PipelineResult(
answer="Encontrei referências mas não consegui recuperar os resumos. Tente novamente.",
pubmed_query=pubmed_query,
answer="Não encontrei uma resposta relevante no dataset para essa pergunta. Tente reformular.",
)

# ── Etapa 4: sintetizar resposta ─────────────────────
await step("Sintetizando resposta com base nas evidências…")
await step("Aprimorando a resposta com base no dataset encontrado…")
final_answer = await build_answer_from_dataset(llm, question, best_item, plain_language)

corpus = "\n\n".join(
f"[{i+1}] {a.title} ({a.journal}, {a.year})\n{a.abstract}"
for i, a in enumerate(articles)
)

style = (
"Responda em linguagem simples e acessível, como se explicasse para uma amiga leiga. "
"Evite jargões. Use frases curtas. Seja empática e encorajadora."
if plain_language else
"Responda de forma científica mas compreensível, citando os estudos com [1], [2], etc. "
"Use parágrafos estruturados."
)
if not final_answer:
final_answer = best_item["output"]

system = (
f"Você é uma assistente especializada em saúde feminina e menopausa, baseada em evidências. "
f"{style} Responda sempre em português do Brasil. "
f"Use apenas as informações dos artigos fornecidos. Não invente dados."
)
user = (
f'Pergunta: "{question}"\n\n'
f"Artigos do PubMed:\n\n{corpus}\n\n"
f"Responda à pergunta com base exclusivamente nesses artigos."
)
return PipelineResult(answer=final_answer)

answer = await llm.complete(system=system, user=user)
return PipelineResult(answer=answer, pubmed_query=pubmed_query, articles=articles)
Loading