GitHub Actions commited on
Commit ·
8aadaef
0
Parent(s):
Sync from GitHub 2026-04-30 19:20:11 UTC
Browse files- .gitignore +26 -0
- Dockerfile +58 -0
- README.md +323 -0
- app/__init__.py +0 -0
- app/core/__init__.py +0 -0
- app/core/state.py +97 -0
- app/main.py +58 -0
- app/models/__init__.py +0 -0
- app/models/requests.py +18 -0
- app/models/responses.py +22 -0
- app/routers/__init__.py +0 -0
- app/routers/documents.py +57 -0
- app/routers/presentations.py +38 -0
- app/routers/query.py +12 -0
- app/services/__init__.py +0 -0
- app/services/helpers.py +463 -0
- app/services/indexing.py +154 -0
- app/services/llm.py +258 -0
- app/services/rag.py +430 -0
- configs/config.json +58 -0
- configs/prompts.json +40 -0
- pyproject.toml +19 -0
- requirements.txt +20 -0
- scripts/build_index.py +52 -0
- scripts/embd_index.py +39 -0
- scripts/generate_embeddings.py +39 -0
- scripts/index_to_elasticsearch.py +104 -0
- scripts/normalize_md_titles.py +126 -0
- scripts/test_retrieval_cli.py +138 -0
- utils/base_utils.py +128 -0
- utils/conversation_word_export.py +183 -0
- utils/md_to_faiss.py +364 -0
- utils/pdf_md.py +116 -0
- utils/retrieval_utils.py +187 -0
- utils/retrievers.py +229 -0
.gitignore
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ambiente Python
|
| 2 |
+
venv/
|
| 3 |
+
.env
|
| 4 |
+
*.pyc
|
| 5 |
+
__pycache__/
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# Configurações e credenciais sensíveis
|
| 9 |
+
config/*.json
|
| 10 |
+
!configs/config.json
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# Ignorar archivos del índice generado
|
| 14 |
+
data/index/
|
| 15 |
+
data/embeddings/
|
| 16 |
+
data/chunks/
|
| 17 |
+
|
| 18 |
+
/Docs/
|
| 19 |
+
/Docs/**
|
| 20 |
+
|
| 21 |
+
# Arquivos temporários / editor
|
| 22 |
+
*.log
|
| 23 |
+
*.tmp
|
| 24 |
+
.DS_Store
|
| 25 |
+
.vscode/
|
| 26 |
+
.idea/
|
Dockerfile
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── Stage 1: Build Vue frontend ──────────────────────────────────────────────
|
| 2 |
+
FROM node:20-alpine AS frontend-build
|
| 3 |
+
WORKDIR /frontend
|
| 4 |
+
RUN apk add --no-cache git
|
| 5 |
+
RUN git clone https://github.com/ICA-PUC/chatbot-norm-frontend.git .
|
| 6 |
+
RUN npm ci && npm run build
|
| 7 |
+
|
| 8 |
+
# ── Stage 2: Python backend ───────────────────────────────────────────────────
|
| 9 |
+
FROM python:3.11-slim
|
| 10 |
+
|
| 11 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 12 |
+
ENV PYTHONUNBUFFERED=1
|
| 13 |
+
|
| 14 |
+
WORKDIR /app
|
| 15 |
+
|
| 16 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 17 |
+
build-essential \
|
| 18 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 19 |
+
|
| 20 |
+
COPY requirements.txt .
|
| 21 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 22 |
+
pip install --no-cache-dir -r requirements.txt && \
|
| 23 |
+
pip install --no-cache-dir huggingface_hub
|
| 24 |
+
|
| 25 |
+
COPY . .
|
| 26 |
+
|
| 27 |
+
# Descargar el índice FAISS desde HF Dataset
|
| 28 |
+
# El token HF_TOKEN se inyecta como build-arg para repos privados.
|
| 29 |
+
# Si el dataset es público, omitir --build-arg HF_TOKEN.
|
| 30 |
+
ARG HF_TOKEN
|
| 31 |
+
RUN mkdir -p data/index/intfloat/multilingual-e5-large && \
|
| 32 |
+
python - <<'EOF'
|
| 33 |
+
import os
|
| 34 |
+
from huggingface_hub import hf_hub_download
|
| 35 |
+
|
| 36 |
+
token = os.getenv("HF_TOKEN") or None
|
| 37 |
+
repo = "ICA-PUC/norm-index"
|
| 38 |
+
dest = "data/index/intfloat/multilingual-e5-large"
|
| 39 |
+
|
| 40 |
+
for filename in ["faiss.index", "metadata.jsonl"]:
|
| 41 |
+
path = hf_hub_download(
|
| 42 |
+
repo_id=repo,
|
| 43 |
+
filename=filename,
|
| 44 |
+
repo_type="dataset",
|
| 45 |
+
token=token,
|
| 46 |
+
local_dir=dest,
|
| 47 |
+
)
|
| 48 |
+
print(f"Downloaded: {path}")
|
| 49 |
+
EOF
|
| 50 |
+
|
| 51 |
+
# Copiar el build del frontend
|
| 52 |
+
COPY --from=frontend-build /frontend/dist /app/static
|
| 53 |
+
|
| 54 |
+
# Puerto requerido por Hugging Face Spaces
|
| 55 |
+
ENV PORT=7860
|
| 56 |
+
EXPOSE 7860
|
| 57 |
+
|
| 58 |
+
CMD uvicorn app.main:app --host 0.0.0.0 --port ${PORT}
|
README.md
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: beta-NORM
|
| 3 |
+
emoji: 🧪
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# NORM Chatbot – Backend (RAG API)
|
| 11 |
+
|
| 12 |
+
A **Retrieval-Augmented Generation (RAG)** system specialized in
|
| 13 |
+
technical documents about **Naturally Occurring Radioactive Materials
|
| 14 |
+
(NORM)** and related chemistry (barium, ²²⁶Ra/²²⁸Ra, marine sediments,
|
| 15 |
+
etc.). This repository contains **only the backend**; the Vue 3 frontend
|
| 16 |
+
lives in [`chatbot-norm-frontend`](https://github.com/ICA-PUC/chatbot-norm-frontend).
|
| 17 |
+
|
| 18 |
+
Users ask questions in natural language and the system answers in
|
| 19 |
+
Portuguese, always citing the passages used with numbered references
|
| 20 |
+
in `[n]` format.
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## Table of contents
|
| 25 |
+
|
| 26 |
+
- [Architecture](#architecture)
|
| 27 |
+
- [Stack](#stack)
|
| 28 |
+
- [Setup](#setup)
|
| 29 |
+
- [Data pipeline](#data-pipeline)
|
| 30 |
+
- [Run the API](#run-the-api)
|
| 31 |
+
- [Endpoints](#endpoints)
|
| 32 |
+
- [Configuration (`configs/config.json`)](#configuration-configsconfigjson)
|
| 33 |
+
- [Repository layout](#repository-layout)
|
| 34 |
+
- [Frontend](#frontend)
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## Architecture
|
| 39 |
+
|
| 40 |
+
1. **Corpus**: `.md` files with YAML frontmatter (`title`, `authors`,
|
| 41 |
+
`publication_year`, `topic`…) stored in `Docs/`.
|
| 42 |
+
2. **Indexing**: token-based chunking → E5 embeddings → FAISS
|
| 43 |
+
(`IndexFlatIP`, cosine) locally, or optional vector Elasticsearch.
|
| 44 |
+
3. **RAG API (FastAPI)**: retrieves the `top_k` chunks, builds the
|
| 45 |
+
prompt with numbered references and calls the LLM (OpenAI).
|
| 46 |
+
4. **Frontend**: a Vue 3 SPA (separate repo) that consumes this API.
|
| 47 |
+
|
| 48 |
+
### Selective index sampling
|
| 49 |
+
|
| 50 |
+
`build_faiss_from_md` supports:
|
| 51 |
+
|
| 52 |
+
- `require_frontmatter`: skips documents without valid YAML frontmatter.
|
| 53 |
+
- `priority_keywords` + `priority_max`: guarantees that key topics
|
| 54 |
+
(barium, NORM, ²²⁶/²²⁸Ra, marine sediment, TENORM) always make it
|
| 55 |
+
into the sample, and reserves a quota for random variety.
|
| 56 |
+
- `shuffle_sample` + `random_seed`: reproducible random sampling for
|
| 57 |
+
the rest.
|
| 58 |
+
|
| 59 |
+
### Embedding model
|
| 60 |
+
|
| 61 |
+
Default: `intfloat/multilingual-e5-large`. This model requires
|
| 62 |
+
prefixes:
|
| 63 |
+
|
| 64 |
+
- chunks → `passage: {text}`
|
| 65 |
+
- queries → `query: {question}`
|
| 66 |
+
|
| 67 |
+
Both prefixes are applied automatically by the `_model_needs_e5_prefix`
|
| 68 |
+
helper. **If you change the embedding model you must reindex.**
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
## Stack
|
| 73 |
+
|
| 74 |
+
- Python ≥ 3.9
|
| 75 |
+
- FastAPI + uvicorn
|
| 76 |
+
- sentence-transformers (multilingual E5)
|
| 77 |
+
- FAISS (local) / Elasticsearch (optional)
|
| 78 |
+
- LangChain text-splitters + tiktoken
|
| 79 |
+
- OpenAI (`gpt-4o-mini` by default)
|
| 80 |
+
|
| 81 |
+
See `requirements.txt` for pinned versions.
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## Setup
|
| 86 |
+
|
| 87 |
+
```bash
|
| 88 |
+
# 1. Clone and create a virtual environment
|
| 89 |
+
git clone https://github.com/ICA-PUC/chatbot-Norm.git
|
| 90 |
+
cd chatbot-Norm
|
| 91 |
+
python3 -m venv venv
|
| 92 |
+
source venv/bin/activate
|
| 93 |
+
|
| 94 |
+
# 2. Install dependencies
|
| 95 |
+
pip install --upgrade pip
|
| 96 |
+
pip install -r requirements.txt
|
| 97 |
+
|
| 98 |
+
# 3. OpenAI key
|
| 99 |
+
export OPENAI_API_KEY="sk-..."
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
---
|
| 103 |
+
|
| 104 |
+
## Data pipeline
|
| 105 |
+
|
| 106 |
+
Run all commands from the repository root with the virtual environment
|
| 107 |
+
activated.
|
| 108 |
+
|
| 109 |
+
### 1. (Optional) Normalize `.md` titles
|
| 110 |
+
|
| 111 |
+
```bash
|
| 112 |
+
python -m scripts.normalize_md_titles
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
Ensures every file under `Docs/` has a clean `# heading`. Useful only
|
| 116 |
+
if the corpus comes from heterogeneous sources without frontmatter.
|
| 117 |
+
|
| 118 |
+
### 2. Build the FAISS index
|
| 119 |
+
|
| 120 |
+
The consolidated script reads the config and generates embeddings +
|
| 121 |
+
index in a single step:
|
| 122 |
+
|
| 123 |
+
```bash
|
| 124 |
+
python -m scripts.embd_index
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
It uses the parameters in the `embeddings` block of
|
| 128 |
+
`configs/config.json`:
|
| 129 |
+
|
| 130 |
+
- `max_files_for_debug`: total number of documents to index (e.g. 900).
|
| 131 |
+
- `shuffle_sample` + `random_seed`: reproducible randomization.
|
| 132 |
+
- `require_frontmatter`: filters out documents without metadata.
|
| 133 |
+
- `priority_keywords` + `priority_max`: guaranteed quota for key
|
| 134 |
+
topics.
|
| 135 |
+
|
| 136 |
+
Outputs:
|
| 137 |
+
|
| 138 |
+
```
|
| 139 |
+
data/index/<model_name>/faiss.index
|
| 140 |
+
data/index/<model_name>/metadata.jsonl
|
| 141 |
+
data/index/<model_name>/index_info.json
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
### 2b. (Legacy alternative) Generate embeddings and build the index separately
|
| 145 |
+
|
| 146 |
+
```bash
|
| 147 |
+
python -m scripts.generate_embeddings
|
| 148 |
+
python -m scripts.build_index
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
Generates `data/embeddings/embeddings.npy` + `metadata.jsonl` and then
|
| 152 |
+
builds the flat FAISS index.
|
| 153 |
+
|
| 154 |
+
### 3. (Optional) Index in Elasticsearch
|
| 155 |
+
|
| 156 |
+
```bash
|
| 157 |
+
export ELASTIC_API_KEY="..."
|
| 158 |
+
python -m scripts.index_to_elasticsearch
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
Then change `index.type` to `"elasticsearch"` in
|
| 162 |
+
`configs/config.json`.
|
| 163 |
+
|
| 164 |
+
### 4. (Dev) CLI smoke test
|
| 165 |
+
|
| 166 |
+
```bash
|
| 167 |
+
python -m scripts.test_retrieval_cli "what is the 226Ra activity in marine sediments?"
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
---
|
| 171 |
+
|
| 172 |
+
## Run the API
|
| 173 |
+
|
| 174 |
+
```bash
|
| 175 |
+
export OPENAI_API_KEY="sk-..."
|
| 176 |
+
uvicorn app.main:app --reload --port 8000
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
The API is served at `http://127.0.0.1:8000`. Interactive docs at
|
| 180 |
+
`http://127.0.0.1:8000/docs`.
|
| 181 |
+
|
| 182 |
+
### CORS
|
| 183 |
+
|
| 184 |
+
Allowed origins are configured via the `ALLOWED_ORIGINS` environment
|
| 185 |
+
variable (comma-separated). By default `localhost`/`127.0.0.1` on any
|
| 186 |
+
port are accepted for development.
|
| 187 |
+
|
| 188 |
+
### Docker
|
| 189 |
+
|
| 190 |
+
```bash
|
| 191 |
+
docker build -t chatbot-norm .
|
| 192 |
+
docker run -p 8000:8000 -e OPENAI_API_KEY=sk-... chatbot-norm
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
---
|
| 196 |
+
|
| 197 |
+
## Endpoints
|
| 198 |
+
|
| 199 |
+
| Method | Path | Description |
|
| 200 |
+
|---|---|---|
|
| 201 |
+
| `GET` | `/health` | Health check. |
|
| 202 |
+
| `GET` | `/list_documents` | Lists all indexed documents (`id`, `title`). |
|
| 203 |
+
| `POST` | `/query` | RAG question; returns the answer and the chunks used. |
|
| 204 |
+
| `POST` | `/precheck_document` | Checks if a file is thematically aligned before indexing. |
|
| 205 |
+
| `POST` | `/upload_document` | Indexes a new file (`.md`, `.txt`, `.pdf`, `.docx`). |
|
| 206 |
+
| `POST` | `/generate_presentation` | Builds a `.pptx` from a list of ideas. |
|
| 207 |
+
|
| 208 |
+
### `POST /query`
|
| 209 |
+
|
| 210 |
+
```json
|
| 211 |
+
{
|
| 212 |
+
"question": "Qual a concentração de 226Ra em sedimentos marinhos?",
|
| 213 |
+
"mode": "chatbot",
|
| 214 |
+
"top_k": 12,
|
| 215 |
+
"temperature": 0.1
|
| 216 |
+
}
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
Available modes: `chatbot`, `summary`, `summary_multi`,
|
| 220 |
+
`summary_sections`, `summary_sections_multi`, `table`, `table_multi`.
|
| 221 |
+
|
| 222 |
+
Response:
|
| 223 |
+
|
| 224 |
+
```json
|
| 225 |
+
{
|
| 226 |
+
"answer": "A concentração de 226Ra em sedimentos marinhos varia [1][2]…",
|
| 227 |
+
"retrieved": [
|
| 228 |
+
{
|
| 229 |
+
"document_id": "226Ra_...",
|
| 230 |
+
"document_title": "...",
|
| 231 |
+
"fragment_id": "...",
|
| 232 |
+
"content": "...",
|
| 233 |
+
"citation_id": 1
|
| 234 |
+
}
|
| 235 |
+
]
|
| 236 |
+
}
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
### `POST /upload_document`
|
| 240 |
+
|
| 241 |
+
`multipart/form-data` with `file`. Max 10 MB. Only available when
|
| 242 |
+
`index.type = "faiss"`. Performs hot reindexing without restarting the
|
| 243 |
+
server.
|
| 244 |
+
|
| 245 |
+
---
|
| 246 |
+
|
| 247 |
+
## Configuration (`configs/config.json`)
|
| 248 |
+
|
| 249 |
+
Main blocks:
|
| 250 |
+
|
| 251 |
+
- **`embeddings`**: model, quotas, priority keywords.
|
| 252 |
+
- **`splitter`**: chunking type (`tokens`), `chunk_size`, `overlap`.
|
| 253 |
+
For E5-large, `chunk_size ≤ 450` is recommended (the model caps at
|
| 254 |
+
512 tokens).
|
| 255 |
+
- **`retrieve`**: `top_k` (default) and `chatbot_top_k`.
|
| 256 |
+
- **`paths`**: input/output paths.
|
| 257 |
+
- **`index`**: `faiss` or `elasticsearch`.
|
| 258 |
+
- **`llm`**: provider, model and `system_prompt`.
|
| 259 |
+
- **`ui.api_url`**: used by the frontend.
|
| 260 |
+
|
| 261 |
+
`configs/prompts.json` holds the per-mode prompts, trigger word lists
|
| 262 |
+
(greetings/smalltalk) and fallback messages.
|
| 263 |
+
|
| 264 |
+
---
|
| 265 |
+
|
| 266 |
+
## Repository layout
|
| 267 |
+
|
| 268 |
+
```
|
| 269 |
+
app/
|
| 270 |
+
├── main.py # FastAPI entry point (uvicorn app.main:app)
|
| 271 |
+
├── core/
|
| 272 |
+
│ └── state.py # Global state + embed_query / embed_chunks helpers
|
| 273 |
+
├── models/
|
| 274 |
+
│ ├── requests.py # Pydantic: QueryRequest, PresentationRequest…
|
| 275 |
+
│ └── responses.py # Pydantic: QueryResponse, PrecheckResponse…
|
| 276 |
+
├── routers/
|
| 277 |
+
│ ├── query.py # POST /query
|
| 278 |
+
│ ├── documents.py # /list_documents, /precheck_document, /upload_document
|
| 279 |
+
│ └── presentations.py # POST /generate_presentation
|
| 280 |
+
└── services/
|
| 281 |
+
├── rag.py # Orchestration of a RAG query
|
| 282 |
+
├── indexing.py # Precheck + upload + hot reindexing
|
| 283 |
+
├── helpers.py # Text utilities, topic catalog, parsing
|
| 284 |
+
└── llm.py # LLM calls + PPTX generation
|
| 285 |
+
|
| 286 |
+
utils/
|
| 287 |
+
├── base_utils.py # Config/prompts loader, I/O helpers
|
| 288 |
+
├── retrieval_utils.py # Embedding batches + splitter + E5 prefixes
|
| 289 |
+
├── retrievers.py # FaissRetriever + ElasticRetriever
|
| 290 |
+
├── md_to_faiss.py # Incremental indexing with priority_keywords
|
| 291 |
+
└── pdf_md.py # .pdf / .docx → Markdown conversion
|
| 292 |
+
|
| 293 |
+
scripts/
|
| 294 |
+
├── embd_index.py # Consolidated pipeline (recommended)
|
| 295 |
+
├── generate_embeddings.py # Legacy: embeddings only
|
| 296 |
+
├── build_index.py # Legacy: build FAISS from .npy
|
| 297 |
+
├── index_to_elasticsearch.py # Push to Elastic Cloud
|
| 298 |
+
├── normalize_md_titles.py
|
| 299 |
+
└── test_retrieval_cli.py
|
| 300 |
+
|
| 301 |
+
configs/
|
| 302 |
+
├── config.json # Single source of parameters
|
| 303 |
+
└── prompts.json # Per-mode prompts + triggers
|
| 304 |
+
|
| 305 |
+
Docs/ # .md corpus with YAML frontmatter
|
| 306 |
+
data/
|
| 307 |
+
├── embeddings/ # embeddings.npy + metadata.jsonl (legacy)
|
| 308 |
+
└── index/<model>/ # faiss.index + metadata.jsonl + index_info.json
|
| 309 |
+
```
|
| 310 |
+
|
| 311 |
+
---
|
| 312 |
+
|
| 313 |
+
## Frontend
|
| 314 |
+
|
| 315 |
+
The Vue 3 + Vite client lives in the sibling repository:
|
| 316 |
+
|
| 317 |
+
- Repo: <https://github.com/ICA-PUC/chatbot-norm-frontend>
|
| 318 |
+
- Dev: `npm run dev` → `http://localhost:5173`
|
| 319 |
+
- Configurable via `VITE_API_URL` (default `http://localhost:8000`).
|
| 320 |
+
|
| 321 |
+
---
|
| 322 |
+
|
| 323 |
+
**Authors:** Breinner Espinosa, Andrés Cotrino.
|
app/__init__.py
ADDED
|
File without changes
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/state.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Estado global de la aplicación: configuración, retriever,
|
| 3 |
+
modelo de embeddings y metadata. Todos los módulos que
|
| 4 |
+
necesiten acceder o mutar el estado deben importar este módulo
|
| 5 |
+
como `import app.core.state as state` y acceder a sus atributos.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from threading import Lock
|
| 9 |
+
from typing import Any, Dict, List
|
| 10 |
+
|
| 11 |
+
import faiss
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
from utils import base_utils as bu
|
| 15 |
+
from utils import retrieval_utils as ru
|
| 16 |
+
from utils.retrievers import get_retriever
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
# Funciones de inicialización (no dependen de estado global)
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
def load_embedding_model(config: dict):
|
| 24 |
+
model_name = config["embeddings"]["model_name"]
|
| 25 |
+
return ru.load_model(model_name)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _needs_e5_prefix() -> bool:
|
| 29 |
+
"""Los modelos intfloat/e5-* requieren los prefijos `query:` / `passage:`."""
|
| 30 |
+
name = str(CONFIG.get("embeddings", {}).get("model_name", "")).lower()
|
| 31 |
+
return "e5" in name
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def embed_query(model, text: str, normalize: bool = True) -> np.ndarray:
|
| 35 |
+
if _needs_e5_prefix() and not text.lstrip().lower().startswith("query:"):
|
| 36 |
+
text = f"query: {text}"
|
| 37 |
+
vec = model.encode(text)
|
| 38 |
+
vec = np.asarray(vec, dtype="float32")[None, :]
|
| 39 |
+
if normalize:
|
| 40 |
+
faiss.normalize_L2(vec)
|
| 41 |
+
return vec
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
# Estado global (inicializado al importar el módulo, igual que antes)
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
|
| 48 |
+
CONFIG: Dict[str, Any] = bu.load_config("configs/config.json")
|
| 49 |
+
RETRIEVER = get_retriever(CONFIG)
|
| 50 |
+
EMBED_MODEL = load_embedding_model(CONFIG)
|
| 51 |
+
PROMPTS: Dict[str, Any] = bu.load_prompts()
|
| 52 |
+
|
| 53 |
+
META_PROMPTS: Dict[str, Any] = (
|
| 54 |
+
PROMPTS.get("meta", {}) if isinstance(PROMPTS, dict) else {}
|
| 55 |
+
)
|
| 56 |
+
TRIGGERS: Dict[str, Any] = (
|
| 57 |
+
META_PROMPTS.get("triggers", {}) if isinstance(META_PROMPTS, dict) else {}
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
NO_INFO_PREFIX: str = META_PROMPTS.get(
|
| 61 |
+
"no_information_prefix",
|
| 62 |
+
"Não há informações suficientes na base.",
|
| 63 |
+
)
|
| 64 |
+
ABOUT_BOT_TEXT: str = META_PROMPTS.get(
|
| 65 |
+
"about_bot",
|
| 66 |
+
(
|
| 67 |
+
"Sou o Chatbot NORM, um assistente especializado em química e materiais "
|
| 68 |
+
"radioativos de ocorrência natural (NORM). Respondo com base nos documentos "
|
| 69 |
+
"técnicos indexados na base de conhecimento deste sistema."
|
| 70 |
+
),
|
| 71 |
+
)
|
| 72 |
+
GREETING_TEXT: str = META_PROMPTS.get(
|
| 73 |
+
"greeting",
|
| 74 |
+
(
|
| 75 |
+
"Olá! Sou o Chatbot NORM, um assistente treinado para responder perguntas "
|
| 76 |
+
"sobre química e NORM usando os documentos técnicos indexados. Você pode, "
|
| 77 |
+
"por exemplo, pedir um resumo de um documento específico ou perguntar sobre "
|
| 78 |
+
"um isótopo radioativo."
|
| 79 |
+
),
|
| 80 |
+
)
|
| 81 |
+
HELP_SCOPE_TEXT: str = META_PROMPTS.get(
|
| 82 |
+
"help_scope",
|
| 83 |
+
(
|
| 84 |
+
"Posso ajudar respondendo perguntas sobre química, radioatividade natural "
|
| 85 |
+
"(NORM), isótopos específicos e documentos técnicos indexados neste sistema. "
|
| 86 |
+
"Faça perguntas objetivas e, se possível, mencione o tema ou documento de "
|
| 87 |
+
"interesse para eu recuperar os trechos mais relevantes."
|
| 88 |
+
),
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
METADATA: List[Dict[str, Any]] = getattr(RETRIEVER, "metadata", [])
|
| 92 |
+
|
| 93 |
+
# Lista de documentos mostrados na última listagem (persistida por sessão do servidor)
|
| 94 |
+
LAST_LISTED_DOCS: List[Dict[str, Any]] = []
|
| 95 |
+
|
| 96 |
+
# Lock para evitar condições de corrida ao reindexar documentos
|
| 97 |
+
INDEX_UPDATE_LOCK = Lock()
|
app/main.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Punto de entrada de la API. Ejecutar con:
|
| 3 |
+
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import pathlib
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from fastapi.staticfiles import StaticFiles
|
| 12 |
+
|
| 13 |
+
from app.routers import documents, presentations, query
|
| 14 |
+
|
| 15 |
+
app = FastAPI(
|
| 16 |
+
title="chatbot-Norm API",
|
| 17 |
+
version="0.2.0",
|
| 18 |
+
description="RAG API para consultas sobre NORM y química.",
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
# CORS — ajustar ALLOWED_ORIGINS en producción vía variable de entorno
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
_raw_origins = os.getenv(
|
| 25 |
+
"ALLOWED_ORIGINS",
|
| 26 |
+
"http://localhost:3000,http://localhost:5173,"
|
| 27 |
+
"http://127.0.0.1:3000,http://127.0.0.1:5173",
|
| 28 |
+
)
|
| 29 |
+
allowed_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()]
|
| 30 |
+
|
| 31 |
+
app.add_middleware(
|
| 32 |
+
CORSMiddleware,
|
| 33 |
+
allow_origins=allowed_origins,
|
| 34 |
+
allow_origin_regex=r"https?://(localhost|127\.0\.0\.1)(:\d+)?",
|
| 35 |
+
allow_credentials=True,
|
| 36 |
+
allow_methods=["*"],
|
| 37 |
+
allow_headers=["*"],
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
# Routers
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
app.include_router(query.router)
|
| 44 |
+
app.include_router(documents.router)
|
| 45 |
+
app.include_router(presentations.router)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@app.get("/health")
|
| 49 |
+
async def health_check():
|
| 50 |
+
return {"status": "ok"}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
# SPA — debe ir al final para no solapar las rutas de la API
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
_static_dir = pathlib.Path(__file__).parent / "static"
|
| 57 |
+
if _static_dir.exists():
|
| 58 |
+
app.mount("/", StaticFiles(directory=_static_dir, html=True), name="spa")
|
app/models/__init__.py
ADDED
|
File without changes
|
app/models/requests.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class QueryRequest(BaseModel):
|
| 7 |
+
question: str
|
| 8 |
+
mode: str
|
| 9 |
+
top_k: int | None = None
|
| 10 |
+
temperature: float | None = None
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class PresentationRequest(BaseModel):
|
| 14 |
+
ideas: List[str]
|
| 15 |
+
title: str | None = None
|
| 16 |
+
audience: str | None = None
|
| 17 |
+
language: str | None = None
|
| 18 |
+
num_slides: int | None = None
|
app/models/responses.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class QueryResponse(BaseModel):
|
| 7 |
+
answer: str
|
| 8 |
+
retrieved: List[Dict[str, Any]]
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class UploadResponse(BaseModel):
|
| 12 |
+
ok: bool
|
| 13 |
+
message: str
|
| 14 |
+
filename: str
|
| 15 |
+
document_id: str
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class PrecheckResponse(BaseModel):
|
| 19 |
+
score: float
|
| 20 |
+
recommendation: str
|
| 21 |
+
rationale: str
|
| 22 |
+
matched_topics: List[str]
|
app/routers/__init__.py
ADDED
|
File without changes
|
app/routers/documents.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
from typing import Any, Dict
|
| 3 |
+
|
| 4 |
+
from fastapi import APIRouter, File, HTTPException, UploadFile
|
| 5 |
+
|
| 6 |
+
import app.core.state as state
|
| 7 |
+
from app.models.responses import PrecheckResponse, UploadResponse
|
| 8 |
+
from app.services.helpers import (
|
| 9 |
+
get_topics_catalog_from_metadata,
|
| 10 |
+
list_documents_from_metadata,
|
| 11 |
+
resolve_topic_from_question,
|
| 12 |
+
safe_basename,
|
| 13 |
+
contains_any_trigger,
|
| 14 |
+
content_from_upload,
|
| 15 |
+
)
|
| 16 |
+
from app.services.indexing import precheck_content, upload_and_index
|
| 17 |
+
|
| 18 |
+
router = APIRouter()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.post("/precheck_document", response_model=PrecheckResponse)
|
| 22 |
+
async def precheck_document(file: UploadFile = File(...)) -> PrecheckResponse:
|
| 23 |
+
"""Evalúa si un archivo parece alineado al scope antes de indexar."""
|
| 24 |
+
safe_name = safe_basename(file.filename or "")
|
| 25 |
+
if not safe_name:
|
| 26 |
+
raise HTTPException(status_code=400, detail="Nome de arquivo inválido.")
|
| 27 |
+
|
| 28 |
+
ext = Path(safe_name).suffix.lower()
|
| 29 |
+
if ext not in {".md", ".txt", ".pdf", ".docx"}:
|
| 30 |
+
raise HTTPException(
|
| 31 |
+
status_code=400,
|
| 32 |
+
detail="Formato não suportado. Use .md, .txt, .pdf ou .docx.",
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
file_bytes = await file.read()
|
| 36 |
+
if not file_bytes:
|
| 37 |
+
raise HTTPException(status_code=400, detail="Arquivo vazio.")
|
| 38 |
+
|
| 39 |
+
content = content_from_upload(file_bytes, ext)
|
| 40 |
+
return precheck_content(safe_name, content)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@router.post("/upload_document", response_model=UploadResponse)
|
| 44 |
+
async def upload_document(file: UploadFile = File(...)) -> UploadResponse:
|
| 45 |
+
"""Recibe un archivo, lo indexa en FAISS y recarga el retriever en memoria."""
|
| 46 |
+
file_bytes = await file.read()
|
| 47 |
+
return upload_and_index(file_bytes, file.filename or "")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@router.get("/list_documents")
|
| 51 |
+
async def list_documents() -> Dict[str, Any]:
|
| 52 |
+
"""Retorna la lista de documentos presentes en la metadata, incluyendo topic."""
|
| 53 |
+
# Siempre usamos la metadata local para exponer también el campo `topic`,
|
| 54 |
+
# que el método del retriever puede no incluir.
|
| 55 |
+
documentos_ordenados = list_documents_from_metadata()
|
| 56 |
+
state.LAST_LISTED_DOCS = documentos_ordenados
|
| 57 |
+
return {"documents": documentos_ordenados}
|
app/routers/presentations.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from fastapi.responses import FileResponse
|
| 3 |
+
|
| 4 |
+
from app.models.requests import PresentationRequest
|
| 5 |
+
from app.services.helpers import safe_basename
|
| 6 |
+
from app.services.llm import build_pptx_from_structure, call_llm_for_slides
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@router.post("/generate_presentation")
|
| 12 |
+
async def generate_presentation(payload: PresentationRequest):
|
| 13 |
+
"""Genera un archivo PPTX a partir de una lista de ideas."""
|
| 14 |
+
if not payload.ideas:
|
| 15 |
+
raise HTTPException(status_code=400, detail="Lista de ideias vazia.")
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
structure = call_llm_for_slides(
|
| 19 |
+
ideas=payload.ideas,
|
| 20 |
+
title=payload.title,
|
| 21 |
+
audience=payload.audience,
|
| 22 |
+
language=payload.language,
|
| 23 |
+
num_slides=payload.num_slides,
|
| 24 |
+
)
|
| 25 |
+
pptx_path = build_pptx_from_structure(structure)
|
| 26 |
+
except RuntimeError as exc:
|
| 27 |
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
| 28 |
+
|
| 29 |
+
filename = structure.get("title") or "apresentacao_chatbot_norm"
|
| 30 |
+
safe_name = safe_basename(str(filename)) or "apresentacao_chatbot_norm"
|
| 31 |
+
if not safe_name.lower().endswith(".pptx"):
|
| 32 |
+
safe_name += ".pptx"
|
| 33 |
+
|
| 34 |
+
return FileResponse(
|
| 35 |
+
pptx_path,
|
| 36 |
+
media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
| 37 |
+
filename=safe_name,
|
| 38 |
+
)
|
app/routers/query.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
|
| 3 |
+
from app.models.requests import QueryRequest
|
| 4 |
+
from app.models.responses import QueryResponse
|
| 5 |
+
from app.services.rag import process_query
|
| 6 |
+
|
| 7 |
+
router = APIRouter()
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@router.post("/query", response_model=QueryResponse)
|
| 11 |
+
async def query_endpoint(payload: QueryRequest) -> QueryResponse:
|
| 12 |
+
return process_query(payload)
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/helpers.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Funciones auxiliares puras y semi-puras usadas por los servicios RAG e indexación.
|
| 3 |
+
Las funciones que acceden al estado global importan `app.core.state` como módulo
|
| 4 |
+
para evitar capturas de valores obsoletos.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import tempfile
|
| 10 |
+
import unicodedata
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any, Dict, List, Set
|
| 13 |
+
|
| 14 |
+
from fastapi import HTTPException
|
| 15 |
+
|
| 16 |
+
import app.core.state as state
|
| 17 |
+
from utils.pdf_md import convert_document_to_markdown
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
# Normalización de texto
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
|
| 24 |
+
def normalizar_texto(texto: str) -> str:
|
| 25 |
+
"""Limpia saltos de línea y espacios raros en los fragmentos."""
|
| 26 |
+
if not texto:
|
| 27 |
+
return ""
|
| 28 |
+
texto = texto.replace("\r", " ").replace("\n", " ")
|
| 29 |
+
texto = re.sub(r"\s+", " ", texto)
|
| 30 |
+
return texto.strip()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def normalize_match_text(text: str) -> str:
|
| 34 |
+
if not text:
|
| 35 |
+
return ""
|
| 36 |
+
value = unicodedata.normalize("NFKD", str(text))
|
| 37 |
+
value = "".join(ch for ch in value if not unicodedata.combining(ch))
|
| 38 |
+
value = value.lower().strip()
|
| 39 |
+
value = re.sub(r"[^\w\s]", " ", value)
|
| 40 |
+
value = re.sub(r"\s+", " ", value)
|
| 41 |
+
return value
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
# Extracción de títulos y secciones desde la pregunta
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
|
| 48 |
+
def extract_quoted_titles(question: str) -> List[str]:
|
| 49 |
+
if not question:
|
| 50 |
+
return []
|
| 51 |
+
matches = re.findall(r'["""]([^"""]{5,})["""]', question)
|
| 52 |
+
cleaned = [m.strip() for m in matches if m and m.strip()]
|
| 53 |
+
return list(dict.fromkeys(cleaned))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def detect_target_sections(question_lower: str) -> List[str]:
|
| 57 |
+
"""Detecta una o más secciones objetivo para resumen por sección."""
|
| 58 |
+
section_aliases: Dict[str, List[str]] = {
|
| 59 |
+
"Introdução": ["introdução", "introducao", "introduccion", "introduction"],
|
| 60 |
+
"Metodologia": ["metodologia", "metodología", "methodology"],
|
| 61 |
+
"Resultados": ["resultados", "results"],
|
| 62 |
+
}
|
| 63 |
+
found: List[tuple] = []
|
| 64 |
+
for section_name, aliases in section_aliases.items():
|
| 65 |
+
positions = [
|
| 66 |
+
question_lower.find(alias)
|
| 67 |
+
for alias in aliases
|
| 68 |
+
if alias in question_lower
|
| 69 |
+
]
|
| 70 |
+
if positions:
|
| 71 |
+
found.append((min(positions), section_name))
|
| 72 |
+
found.sort(key=lambda item: item[0])
|
| 73 |
+
return [section_name for _, section_name in found]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ---------------------------------------------------------------------------
|
| 77 |
+
# Triggers
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
|
| 80 |
+
def contains_trigger(text: str, trigger: str) -> bool:
|
| 81 |
+
"""Verifica trigger como palabra/frase completa (evita falsos positivos)."""
|
| 82 |
+
if not text or not trigger:
|
| 83 |
+
return False
|
| 84 |
+
trigger_clean = trigger.strip().lower()
|
| 85 |
+
if not trigger_clean:
|
| 86 |
+
return False
|
| 87 |
+
pattern = re.escape(trigger_clean).replace(r"\ ", r"\s+")
|
| 88 |
+
return re.search(rf"(?<!\w){pattern}(?!\w)", text.lower()) is not None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def contains_any_trigger(text: str, triggers: List[str]) -> bool:
|
| 92 |
+
return any(contains_trigger(text, trigger) for trigger in triggers)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ---------------------------------------------------------------------------
|
| 96 |
+
# Catálogos de documentos y tópicos (acceden al estado global)
|
| 97 |
+
# ---------------------------------------------------------------------------
|
| 98 |
+
|
| 99 |
+
def get_documents_catalog() -> List[Dict[str, str]]:
|
| 100 |
+
"""Obtiene catálogo (id + title) desde el retriever o la metadata."""
|
| 101 |
+
if hasattr(state.RETRIEVER, "list_documents"):
|
| 102 |
+
docs = state.RETRIEVER.list_documents()
|
| 103 |
+
return [
|
| 104 |
+
{
|
| 105 |
+
"id": str(d.get("id", "")),
|
| 106 |
+
"title": str(d.get("title") or d.get("id") or ""),
|
| 107 |
+
}
|
| 108 |
+
for d in docs
|
| 109 |
+
if d.get("id")
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
+
docs_map: Dict[str, str] = {}
|
| 113 |
+
for m in state.METADATA:
|
| 114 |
+
doc_id = m.get("document_id")
|
| 115 |
+
if not doc_id:
|
| 116 |
+
continue
|
| 117 |
+
title = m.get("document_title") or doc_id
|
| 118 |
+
if doc_id not in docs_map:
|
| 119 |
+
docs_map[str(doc_id)] = str(title)
|
| 120 |
+
return [{"id": doc_id, "title": title} for doc_id, title in docs_map.items()]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def get_topics_catalog_from_metadata() -> List[str]:
|
| 124 |
+
"""Lista de tópicos distintos existentes en la metadata."""
|
| 125 |
+
seen: Dict[str, str] = {}
|
| 126 |
+
for m in state.METADATA:
|
| 127 |
+
raw_topic = m.get("topic")
|
| 128 |
+
if not raw_topic:
|
| 129 |
+
continue
|
| 130 |
+
topic = str(raw_topic).strip()
|
| 131 |
+
if not topic:
|
| 132 |
+
continue
|
| 133 |
+
norm = normalize_match_text(topic)
|
| 134 |
+
if norm and norm not in seen:
|
| 135 |
+
seen[norm] = topic
|
| 136 |
+
return sorted(seen.values(), key=lambda t: t.lower())
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
# Resolución de tópicos y documentos
|
| 141 |
+
# ---------------------------------------------------------------------------
|
| 142 |
+
|
| 143 |
+
def topic_match_score(doc_topic: str, target_topic: str) -> int:
|
| 144 |
+
doc_norm = normalize_match_text(doc_topic)
|
| 145 |
+
target_norm = normalize_match_text(target_topic)
|
| 146 |
+
if not doc_norm or not target_norm:
|
| 147 |
+
return 0
|
| 148 |
+
if doc_norm == target_norm:
|
| 149 |
+
return 3
|
| 150 |
+
if target_norm in doc_norm:
|
| 151 |
+
return 2
|
| 152 |
+
if doc_norm in target_norm and len(doc_norm) >= 8:
|
| 153 |
+
return 1
|
| 154 |
+
return 0
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def resolve_topic_from_question(question: str, topics_catalog: List[str]) -> str | None:
|
| 158 |
+
if not question:
|
| 159 |
+
return None
|
| 160 |
+
quoted = re.findall(
|
| 161 |
+
r'(?:topic|topico|tópico|tema)\s*(?::|=|eh|é|is|del|de|do|da)?\s*["""]([^"""]{3,})["""]',
|
| 162 |
+
question,
|
| 163 |
+
flags=re.IGNORECASE,
|
| 164 |
+
)
|
| 165 |
+
if quoted:
|
| 166 |
+
return quoted[0].strip()
|
| 167 |
+
|
| 168 |
+
question_norm = normalize_match_text(question)
|
| 169 |
+
if not question_norm:
|
| 170 |
+
return None
|
| 171 |
+
|
| 172 |
+
matched = [
|
| 173 |
+
topic for topic in topics_catalog
|
| 174 |
+
if normalize_match_text(topic) in question_norm
|
| 175 |
+
]
|
| 176 |
+
if len(matched) == 1:
|
| 177 |
+
return matched[0]
|
| 178 |
+
|
| 179 |
+
free_text = re.findall(
|
| 180 |
+
r'(?:topic|topico|tópico|tema)\s*(?::|=|eh|é|is|del|de|do|da)?\s*([^,.!?\n]{3,})',
|
| 181 |
+
question,
|
| 182 |
+
flags=re.IGNORECASE,
|
| 183 |
+
)
|
| 184 |
+
if free_text:
|
| 185 |
+
candidate = free_text[0].strip()
|
| 186 |
+
return candidate if candidate else None
|
| 187 |
+
return None
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def list_documents_from_metadata(topic: str | None = None) -> List[Dict[str, str]]:
|
| 191 |
+
"""Lista documentos desde la metadata, opcionalmente filtrados por tópico."""
|
| 192 |
+
resolved_topic = (topic or "").strip()
|
| 193 |
+
docs_map: Dict[str, Dict[str, str]] = {}
|
| 194 |
+
for m in state.METADATA:
|
| 195 |
+
doc_id_raw = m.get("document_id")
|
| 196 |
+
if not doc_id_raw:
|
| 197 |
+
continue
|
| 198 |
+
doc_id = str(doc_id_raw)
|
| 199 |
+
title = str(m.get("document_title") or doc_id)
|
| 200 |
+
doc_topic = str(m.get("topic") or "").strip()
|
| 201 |
+
if resolved_topic and topic_match_score(doc_topic, resolved_topic) == 0:
|
| 202 |
+
continue
|
| 203 |
+
if doc_id not in docs_map:
|
| 204 |
+
docs_map[doc_id] = {"id": doc_id, "title": title, "topic": doc_topic}
|
| 205 |
+
return sorted(docs_map.values(), key=lambda d: d["title"].lower())
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def resolve_doc_ids_from_titles(
|
| 209 |
+
titles: List[str], catalog: List[Dict[str, str]]
|
| 210 |
+
) -> Set[str]:
|
| 211 |
+
resolved: Set[str] = set()
|
| 212 |
+
if not titles or not catalog:
|
| 213 |
+
return resolved
|
| 214 |
+
|
| 215 |
+
normalized_catalog = [
|
| 216 |
+
{
|
| 217 |
+
"id": str(doc.get("id", "")),
|
| 218 |
+
"title": str(doc.get("title", "")),
|
| 219 |
+
"id_norm": normalize_match_text(doc.get("id", "")),
|
| 220 |
+
"norm": normalize_match_text(doc.get("title", "")),
|
| 221 |
+
}
|
| 222 |
+
for doc in catalog
|
| 223 |
+
if doc.get("id")
|
| 224 |
+
]
|
| 225 |
+
|
| 226 |
+
for raw_title in titles:
|
| 227 |
+
target_norm = normalize_match_text(raw_title)
|
| 228 |
+
if not target_norm:
|
| 229 |
+
continue
|
| 230 |
+
|
| 231 |
+
exact = [
|
| 232 |
+
doc for doc in normalized_catalog
|
| 233 |
+
if doc["norm"] == target_norm or doc["id_norm"] == target_norm
|
| 234 |
+
]
|
| 235 |
+
if len(exact) == 1:
|
| 236 |
+
resolved.add(exact[0]["id"])
|
| 237 |
+
continue
|
| 238 |
+
|
| 239 |
+
contains = [
|
| 240 |
+
doc for doc in normalized_catalog
|
| 241 |
+
if (
|
| 242 |
+
target_norm in doc["norm"]
|
| 243 |
+
or (doc["norm"] in target_norm and len(doc["norm"]) >= 20)
|
| 244 |
+
or target_norm in doc["id_norm"]
|
| 245 |
+
or (doc["id_norm"] in target_norm and len(doc["id_norm"]) >= 8)
|
| 246 |
+
)
|
| 247 |
+
]
|
| 248 |
+
if len(contains) == 1:
|
| 249 |
+
resolved.add(contains[0]["id"])
|
| 250 |
+
return resolved
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def extract_explicit_target_doc_ids(
|
| 254 |
+
question: str, catalog: List[Dict[str, str]]
|
| 255 |
+
) -> Set[str]:
|
| 256 |
+
if not question:
|
| 257 |
+
return set()
|
| 258 |
+
|
| 259 |
+
explicit_titles: List[str] = []
|
| 260 |
+
|
| 261 |
+
# Forma singular: `documento alvo principal "X"`
|
| 262 |
+
explicit_titles.extend(
|
| 263 |
+
m.strip()
|
| 264 |
+
for m in re.findall(
|
| 265 |
+
r'documento\s+alvo\s+principal\s+["“”]([^"“”]+)["“”]',
|
| 266 |
+
question,
|
| 267 |
+
flags=re.IGNORECASE,
|
| 268 |
+
)
|
| 269 |
+
if m and m.strip()
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
# Forma plural: `documentos alvo ... "A", "B", "C"` (encabezado opcional)
|
| 273 |
+
plural_match = re.search(
|
| 274 |
+
r'documentos\s+alvo[^:"“”]*[:]?\s*((?:["“”][^"“”]+["“”][\s,;e]*)+)',
|
| 275 |
+
question,
|
| 276 |
+
flags=re.IGNORECASE,
|
| 277 |
+
)
|
| 278 |
+
if plural_match:
|
| 279 |
+
explicit_titles.extend(
|
| 280 |
+
m.strip()
|
| 281 |
+
for m in re.findall(r'["“”]([^"“”]+)["“”]', plural_match.group(1))
|
| 282 |
+
if m and m.strip()
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
return resolve_doc_ids_from_titles(explicit_titles, catalog) if explicit_titles else set()
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
# ---------------------------------------------------------------------------
|
| 289 |
+
# Suplemento y colección de chunks
|
| 290 |
+
# ---------------------------------------------------------------------------
|
| 291 |
+
|
| 292 |
+
def supplement_retrieval_for_target_docs(
|
| 293 |
+
retrieved: List[Dict[str, Any]],
|
| 294 |
+
target_doc_ids: Set[str],
|
| 295 |
+
metadata: List[Dict[str, Any]],
|
| 296 |
+
chunks_per_doc: int = 2,
|
| 297 |
+
) -> List[Dict[str, Any]]:
|
| 298 |
+
if not target_doc_ids:
|
| 299 |
+
return retrieved
|
| 300 |
+
|
| 301 |
+
def _key(item: Dict[str, Any]) -> str:
|
| 302 |
+
idx = item.get("idx")
|
| 303 |
+
if idx is not None:
|
| 304 |
+
return f"idx:{idx}"
|
| 305 |
+
doc_id = str(item.get("document_id", ""))
|
| 306 |
+
fragment_id = item.get("fragment_id")
|
| 307 |
+
if fragment_id is not None:
|
| 308 |
+
return f"doc:{doc_id}|frag:{fragment_id}"
|
| 309 |
+
return f"doc:{doc_id}|content:{str(item.get('content', ''))[:120]}"
|
| 310 |
+
|
| 311 |
+
dedup: Dict[str, Dict[str, Any]] = {_key(item): item for item in retrieved}
|
| 312 |
+
|
| 313 |
+
if not metadata:
|
| 314 |
+
return list(dedup.values())
|
| 315 |
+
|
| 316 |
+
counts: Dict[str, int] = {doc_id: 0 for doc_id in target_doc_ids}
|
| 317 |
+
for item in dedup.values():
|
| 318 |
+
doc_id = str(item.get("document_id", ""))
|
| 319 |
+
if doc_id in counts:
|
| 320 |
+
counts[doc_id] += 1
|
| 321 |
+
|
| 322 |
+
for doc_id in target_doc_ids:
|
| 323 |
+
needed = max(0, chunks_per_doc - counts.get(doc_id, 0))
|
| 324 |
+
if needed == 0:
|
| 325 |
+
continue
|
| 326 |
+
chunks = [m for m in metadata if str(m.get("document_id", "")) == doc_id]
|
| 327 |
+
chunks = sorted(
|
| 328 |
+
chunks,
|
| 329 |
+
key=lambda m: (
|
| 330 |
+
m.get("fragment_id") is None,
|
| 331 |
+
m.get("fragment_id") or 0,
|
| 332 |
+
m.get("idx") or 0,
|
| 333 |
+
),
|
| 334 |
+
)
|
| 335 |
+
for chunk in chunks:
|
| 336 |
+
key = _key(chunk)
|
| 337 |
+
if key in dedup:
|
| 338 |
+
continue
|
| 339 |
+
dedup[key] = dict(chunk)
|
| 340 |
+
needed -= 1
|
| 341 |
+
if needed == 0:
|
| 342 |
+
break
|
| 343 |
+
|
| 344 |
+
return list(dedup.values())
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def collect_metadata_chunks_for_docs(
|
| 348 |
+
metadata: List[Dict[str, Any]],
|
| 349 |
+
target_doc_ids: Set[str],
|
| 350 |
+
max_chunks_per_doc: int | None = None,
|
| 351 |
+
) -> List[Dict[str, Any]]:
|
| 352 |
+
if not metadata or not target_doc_ids:
|
| 353 |
+
return []
|
| 354 |
+
collected: List[Dict[str, Any]] = []
|
| 355 |
+
for doc_id in target_doc_ids:
|
| 356 |
+
chunks = [m for m in metadata if str(m.get("document_id", "")) == doc_id]
|
| 357 |
+
chunks = sorted(
|
| 358 |
+
chunks,
|
| 359 |
+
key=lambda m: (
|
| 360 |
+
m.get("fragment_id") is None,
|
| 361 |
+
m.get("fragment_id") or 0,
|
| 362 |
+
m.get("idx") or 0,
|
| 363 |
+
),
|
| 364 |
+
)
|
| 365 |
+
if max_chunks_per_doc is not None:
|
| 366 |
+
chunks = chunks[:max_chunks_per_doc]
|
| 367 |
+
collected.extend(dict(chunk) for chunk in chunks)
|
| 368 |
+
return collected
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
# ---------------------------------------------------------------------------
|
| 372 |
+
# Respuesta de fallback
|
| 373 |
+
# ---------------------------------------------------------------------------
|
| 374 |
+
|
| 375 |
+
def build_chatbot_fallback_answer(
|
| 376 |
+
retrieved: List[Dict[str, Any]], max_points: int = 3
|
| 377 |
+
) -> str:
|
| 378 |
+
if not retrieved:
|
| 379 |
+
return state.NO_INFO_PREFIX
|
| 380 |
+
|
| 381 |
+
points: List[str] = []
|
| 382 |
+
seen_keys: Set[str] = set()
|
| 383 |
+
|
| 384 |
+
for item in retrieved:
|
| 385 |
+
text = normalizar_texto(item.get("content", ""))
|
| 386 |
+
if len(text) < 40:
|
| 387 |
+
continue
|
| 388 |
+
doc_id = str(item.get("document_id", ""))
|
| 389 |
+
frag = str(item.get("fragment_id") or item.get("idx") or "")
|
| 390 |
+
key = f"{doc_id}:{frag}:{text[:80]}"
|
| 391 |
+
if key in seen_keys:
|
| 392 |
+
continue
|
| 393 |
+
seen_keys.add(key)
|
| 394 |
+
excerpt = text[:260] + ("..." if len(text) > 260 else "")
|
| 395 |
+
cit_id = item.get("citation_id")
|
| 396 |
+
citation = f" [{cit_id}]" if cit_id else ""
|
| 397 |
+
points.append(f"- {excerpt}{citation}")
|
| 398 |
+
if len(points) >= max_points:
|
| 399 |
+
break
|
| 400 |
+
|
| 401 |
+
if not points:
|
| 402 |
+
return state.NO_INFO_PREFIX
|
| 403 |
+
|
| 404 |
+
return (
|
| 405 |
+
"Com base nos trechos recuperados, há informações relacionadas ao tema:\n"
|
| 406 |
+
+ "\n".join(points)
|
| 407 |
+
+ "\n\nSe quiser, posso aprofundar em um tópico específico "
|
| 408 |
+
"(por exemplo, isótopos, matriz ambiental ou metodologia)."
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
# ---------------------------------------------------------------------------
|
| 413 |
+
# Utilidades de archivos / upload
|
| 414 |
+
# ---------------------------------------------------------------------------
|
| 415 |
+
|
| 416 |
+
def resolve_docs_dir_from_config(config: Dict[str, Any]) -> str:
|
| 417 |
+
input_path = str(config.get("paths", {}).get("input_path", "Docs/*.md"))
|
| 418 |
+
if "*" in input_path:
|
| 419 |
+
return os.path.dirname(input_path) or "Docs"
|
| 420 |
+
return input_path if os.path.isdir(input_path) else os.path.dirname(input_path) or "Docs"
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def safe_basename(filename: str) -> str:
|
| 424 |
+
clean = os.path.basename(filename or "").strip().replace("\\", "")
|
| 425 |
+
clean = re.sub(r"\s+", "_", clean)
|
| 426 |
+
clean = re.sub(r"[^A-Za-z0-9._-]", "", clean)
|
| 427 |
+
return clean
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def content_from_upload(file_bytes: bytes, ext: str) -> str:
|
| 431 |
+
"""Convierte el contenido enviado a markdown/texto utilizable."""
|
| 432 |
+
ext = (ext or "").lower()
|
| 433 |
+
|
| 434 |
+
if ext in {".md", ".txt"}:
|
| 435 |
+
for encoding in ("utf-8", "latin-1"):
|
| 436 |
+
try:
|
| 437 |
+
return file_bytes.decode(encoding)
|
| 438 |
+
except UnicodeDecodeError:
|
| 439 |
+
continue
|
| 440 |
+
raise HTTPException(
|
| 441 |
+
status_code=400,
|
| 442 |
+
detail="Não foi possível decodificar o arquivo de texto enviado.",
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
if ext in {".pdf", ".docx"}:
|
| 446 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp_file:
|
| 447 |
+
tmp_file.write(file_bytes)
|
| 448 |
+
tmp_path = tmp_file.name
|
| 449 |
+
try:
|
| 450 |
+
return convert_document_to_markdown(tmp_path, return_text=True) or ""
|
| 451 |
+
except Exception as exc:
|
| 452 |
+
raise HTTPException(
|
| 453 |
+
status_code=400,
|
| 454 |
+
detail=f"Não foi possível converter o arquivo enviado: {exc}",
|
| 455 |
+
) from exc
|
| 456 |
+
finally:
|
| 457 |
+
if os.path.exists(tmp_path):
|
| 458 |
+
os.remove(tmp_path)
|
| 459 |
+
|
| 460 |
+
raise HTTPException(
|
| 461 |
+
status_code=400,
|
| 462 |
+
detail="Formato não suportado. Use .md, .txt, .pdf ou .docx.",
|
| 463 |
+
)
|
app/services/indexing.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Lógica de indexación dinámica de documentos y validación previa (precheck).
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from fastapi import HTTPException
|
| 9 |
+
|
| 10 |
+
import app.core.state as state
|
| 11 |
+
from app.models.responses import PrecheckResponse, UploadResponse
|
| 12 |
+
from app.services.helpers import (
|
| 13 |
+
content_from_upload,
|
| 14 |
+
get_topics_catalog_from_metadata,
|
| 15 |
+
resolve_docs_dir_from_config,
|
| 16 |
+
safe_basename,
|
| 17 |
+
topic_match_score,
|
| 18 |
+
)
|
| 19 |
+
from utils import md_to_faiss
|
| 20 |
+
from utils.retrievers import get_retriever
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
# Precheck temático
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
|
| 27 |
+
def precheck_content(filename: str, content: str) -> PrecheckResponse:
|
| 28 |
+
"""Evalúa alineación temática del contenido antes de la indexación."""
|
| 29 |
+
sample_text = f"{filename}\n\n{content[:20000]}" if content else filename
|
| 30 |
+
inferred_topic = md_to_faiss._infer_topic_from_text(sample_text)
|
| 31 |
+
topics_catalog = get_topics_catalog_from_metadata()
|
| 32 |
+
|
| 33 |
+
matched_topics = []
|
| 34 |
+
if inferred_topic:
|
| 35 |
+
matched_topics = [
|
| 36 |
+
topic for topic in topics_catalog
|
| 37 |
+
if topic_match_score(topic, inferred_topic) > 0
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
if inferred_topic and matched_topics:
|
| 41 |
+
return PrecheckResponse(
|
| 42 |
+
score=0.95,
|
| 43 |
+
recommendation="allow",
|
| 44 |
+
rationale=(
|
| 45 |
+
f"Tema detectado como '{inferred_topic}', "
|
| 46 |
+
"com correspondência no catálogo já indexado."
|
| 47 |
+
),
|
| 48 |
+
matched_topics=matched_topics,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
if inferred_topic:
|
| 52 |
+
return PrecheckResponse(
|
| 53 |
+
score=0.55,
|
| 54 |
+
recommendation="confirm",
|
| 55 |
+
rationale=(
|
| 56 |
+
f"Tema provável detectado: '{inferred_topic}', "
|
| 57 |
+
"mas sem correspondência exata com os tópicos já indexados."
|
| 58 |
+
),
|
| 59 |
+
matched_topics=matched_topics or [inferred_topic],
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
return PrecheckResponse(
|
| 63 |
+
score=0.10,
|
| 64 |
+
recommendation="block",
|
| 65 |
+
rationale=(
|
| 66 |
+
"Não foi possível identificar um tema alinhado ao escopo com confiança suficiente."
|
| 67 |
+
),
|
| 68 |
+
matched_topics=[],
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ---------------------------------------------------------------------------
|
| 73 |
+
# Upload e indexação
|
| 74 |
+
# ---------------------------------------------------------------------------
|
| 75 |
+
|
| 76 |
+
def upload_and_index(file_bytes: bytes, original_filename: str) -> UploadResponse:
|
| 77 |
+
"""Recibe bytes de un archivo, lo convierte, guarda e indexa en FAISS."""
|
| 78 |
+
if state.CONFIG.get("index", {}).get("type", "faiss").lower() != "faiss":
|
| 79 |
+
raise HTTPException(
|
| 80 |
+
status_code=400,
|
| 81 |
+
detail="Upload direto está habilitado apenas quando index.type = 'faiss'.",
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
safe_name = safe_basename(original_filename)
|
| 85 |
+
if not safe_name:
|
| 86 |
+
raise HTTPException(status_code=400, detail="Nome de arquivo inválido.")
|
| 87 |
+
|
| 88 |
+
ext = Path(safe_name).suffix.lower()
|
| 89 |
+
if ext not in {".md", ".txt", ".pdf", ".docx"}:
|
| 90 |
+
raise HTTPException(
|
| 91 |
+
status_code=400,
|
| 92 |
+
detail="Formato não suportado. Use .md, .txt, .pdf ou .docx.",
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
if not file_bytes:
|
| 96 |
+
raise HTTPException(status_code=400, detail="Arquivo vazio.")
|
| 97 |
+
|
| 98 |
+
max_size_bytes = 10 * 1024 * 1024
|
| 99 |
+
if len(file_bytes) > max_size_bytes:
|
| 100 |
+
raise HTTPException(status_code=400, detail="Arquivo excede o limite de 10MB.")
|
| 101 |
+
|
| 102 |
+
content = content_from_upload(file_bytes, ext)
|
| 103 |
+
|
| 104 |
+
docs_dir = resolve_docs_dir_from_config(state.CONFIG)
|
| 105 |
+
os.makedirs(docs_dir, exist_ok=True)
|
| 106 |
+
|
| 107 |
+
stem = Path(safe_name).stem
|
| 108 |
+
target_name = f"{stem}.md"
|
| 109 |
+
target_path = os.path.join(docs_dir, target_name)
|
| 110 |
+
document_id = stem
|
| 111 |
+
|
| 112 |
+
existing_doc_ids = {
|
| 113 |
+
str(m.get("document_id")) for m in state.METADATA if m.get("document_id")
|
| 114 |
+
}
|
| 115 |
+
if document_id in existing_doc_ids:
|
| 116 |
+
raise HTTPException(
|
| 117 |
+
status_code=409,
|
| 118 |
+
detail=(
|
| 119 |
+
"Já existe um documento com esse nome/document_id. "
|
| 120 |
+
"Renomeie o arquivo antes de enviar."
|
| 121 |
+
),
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
with open(target_path, "w", encoding="utf-8") as f:
|
| 125 |
+
f.write(content)
|
| 126 |
+
|
| 127 |
+
with state.INDEX_UPDATE_LOCK:
|
| 128 |
+
try:
|
| 129 |
+
md_to_faiss.build_faiss_from_md(
|
| 130 |
+
input_path=target_path,
|
| 131 |
+
index_dir=state.CONFIG["paths"]["index_dir"],
|
| 132 |
+
model_name=state.CONFIG["embeddings"]["model_name"],
|
| 133 |
+
splitter=state.CONFIG["splitter"]["type"],
|
| 134 |
+
chunk_size=int(state.CONFIG["splitter"]["chunk_size"]),
|
| 135 |
+
chunk_overlap=int(state.CONFIG["splitter"]["overlap"]),
|
| 136 |
+
retrieval_model=state.EMBED_MODEL,
|
| 137 |
+
max_documents=1,
|
| 138 |
+
)
|
| 139 |
+
state.RETRIEVER = get_retriever(state.CONFIG)
|
| 140 |
+
state.METADATA = getattr(state.RETRIEVER, "metadata", [])
|
| 141 |
+
except Exception as exc:
|
| 142 |
+
if os.path.exists(target_path):
|
| 143 |
+
os.remove(target_path)
|
| 144 |
+
raise HTTPException(
|
| 145 |
+
status_code=500,
|
| 146 |
+
detail=f"Falha ao indexar arquivo: {exc}",
|
| 147 |
+
) from exc
|
| 148 |
+
|
| 149 |
+
return UploadResponse(
|
| 150 |
+
ok=True,
|
| 151 |
+
message="Documento enviado e indexado com sucesso.",
|
| 152 |
+
filename=target_name,
|
| 153 |
+
document_id=document_id,
|
| 154 |
+
)
|
app/services/llm.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Llamadas al LLM (OpenAI) y generación de presentaciones PPTX.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import tempfile
|
| 8 |
+
from typing import Any, Dict, List
|
| 9 |
+
|
| 10 |
+
import app.core.state as state
|
| 11 |
+
from app.services.helpers import normalizar_texto
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
# RAG query → LLM
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
|
| 18 |
+
def call_llm(
|
| 19 |
+
question: str,
|
| 20 |
+
context_fragments: List[Dict[str, Any]],
|
| 21 |
+
temperature: float | None = None,
|
| 22 |
+
mode: str = "chatbot",
|
| 23 |
+
) -> str:
|
| 24 |
+
if not context_fragments:
|
| 25 |
+
return "Não há informação suficiente na base. (nenhum trecho recuperado)"
|
| 26 |
+
|
| 27 |
+
config = state.CONFIG
|
| 28 |
+
provider = config.get("llm", {}).get("provider", "openai")
|
| 29 |
+
model_name = config.get("llm", {}).get("model", "gpt-4o-mini")
|
| 30 |
+
|
| 31 |
+
# Mapa de citação numérica por documento
|
| 32 |
+
citation_ids: Dict[str, int] = {}
|
| 33 |
+
next_id = 1
|
| 34 |
+
for m in context_fragments:
|
| 35 |
+
doc_id = m.get("document_id")
|
| 36 |
+
if not doc_id:
|
| 37 |
+
continue
|
| 38 |
+
if doc_id not in citation_ids:
|
| 39 |
+
citation_ids[doc_id] = next_id
|
| 40 |
+
next_id += 1
|
| 41 |
+
|
| 42 |
+
context_lines: List[str] = []
|
| 43 |
+
for m in context_fragments:
|
| 44 |
+
doc_id = m.get("document_id")
|
| 45 |
+
if not doc_id:
|
| 46 |
+
continue
|
| 47 |
+
cit_num = citation_ids.get(doc_id)
|
| 48 |
+
tag = f"[{cit_num}]" if cit_num is not None else ""
|
| 49 |
+
texto = normalizar_texto(m.get("content", ""))
|
| 50 |
+
context_lines.append(f"{tag} {texto}" if tag else texto)
|
| 51 |
+
context_text = "\n".join(context_lines)
|
| 52 |
+
|
| 53 |
+
referencias_lines: List[str] = []
|
| 54 |
+
for doc_id, cit_num in sorted(citation_ids.items(), key=lambda x: x[1]):
|
| 55 |
+
titulo = None
|
| 56 |
+
for m in context_fragments:
|
| 57 |
+
if m.get("document_id") == doc_id:
|
| 58 |
+
titulo = m.get("document_title") or doc_id
|
| 59 |
+
break
|
| 60 |
+
referencias_lines.append(f"[{cit_num}] {titulo}")
|
| 61 |
+
referencias_text = "\n".join(referencias_lines)
|
| 62 |
+
|
| 63 |
+
system_prompts: Dict[str, Any] = (
|
| 64 |
+
state.PROMPTS.get("system_prompts", {}) if isinstance(state.PROMPTS, dict) else {}
|
| 65 |
+
)
|
| 66 |
+
system_prompt = system_prompts.get(mode)
|
| 67 |
+
if not system_prompt:
|
| 68 |
+
system_prompt = (
|
| 69 |
+
"Você é um assistente especializado em química e NORM. "
|
| 70 |
+
"Use SOMENTE os trechos de contexto fornecidos e nunca invente dados. "
|
| 71 |
+
"Se a pergunta mencionar anos posteriores a 2025 (por exemplo 2026 em diante), "
|
| 72 |
+
"responda explicitamente que não há informações disponíveis para esses anos na base "
|
| 73 |
+
"e não tente extrapolar. "
|
| 74 |
+
"Quando citar fontes, use o formato [n], onde n corresponde ao número da referência "
|
| 75 |
+
"na tabela de referências fornecida. "
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
if provider == "openai":
|
| 79 |
+
try:
|
| 80 |
+
from openai import OpenAI
|
| 81 |
+
except ImportError:
|
| 82 |
+
return (
|
| 83 |
+
"Não há informação suficiente na base. "
|
| 84 |
+
"(biblioteca openai não instalada no ambiente)"
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
api_key = os.getenv("OPENAI_API_KEY")
|
| 88 |
+
if not api_key:
|
| 89 |
+
return (
|
| 90 |
+
"Não há informação suficiente na base. "
|
| 91 |
+
"(variável de ambiente OPENAI_API_KEY não configurada)"
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
client = OpenAI(api_key=api_key)
|
| 95 |
+
messages = [
|
| 96 |
+
{"role": "system", "content": system_prompt},
|
| 97 |
+
{
|
| 98 |
+
"role": "user",
|
| 99 |
+
"content": (
|
| 100 |
+
"Contexto:\n" + context_text + "\n\n"
|
| 101 |
+
+ "Referências (por número):\n" + referencias_text + "\n\n"
|
| 102 |
+
+ f"Pergunta: {question}"
|
| 103 |
+
),
|
| 104 |
+
},
|
| 105 |
+
]
|
| 106 |
+
temperature_value = 0.5 if temperature is None else float(temperature)
|
| 107 |
+
resp = client.chat.completions.create(
|
| 108 |
+
model=model_name,
|
| 109 |
+
messages=messages,
|
| 110 |
+
temperature=temperature_value,
|
| 111 |
+
)
|
| 112 |
+
return resp.choices[0].message.content.strip()
|
| 113 |
+
|
| 114 |
+
# Fallback para provider não suportado
|
| 115 |
+
resumen = []
|
| 116 |
+
for line in context_lines:
|
| 117 |
+
if len(line) > 300:
|
| 118 |
+
line = line[:300] + "..."
|
| 119 |
+
resumen.append(line)
|
| 120 |
+
return "Provvedor de LLM não suportado; mostrando trechos brutos:\n" + "\n".join(resumen)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ---------------------------------------------------------------------------
|
| 124 |
+
# Slides → LLM
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
|
| 127 |
+
def call_llm_for_slides(
|
| 128 |
+
ideas: List[str],
|
| 129 |
+
title: str | None = None,
|
| 130 |
+
audience: str | None = None,
|
| 131 |
+
language: str | None = None,
|
| 132 |
+
num_slides: int | None = None,
|
| 133 |
+
temperature: float | None = 0.4,
|
| 134 |
+
) -> dict:
|
| 135 |
+
config = state.CONFIG
|
| 136 |
+
provider = config.get("llm", {}).get("provider", "openai")
|
| 137 |
+
model_name = config.get("llm", {}).get("model", "gpt-4o-mini")
|
| 138 |
+
|
| 139 |
+
system_prompts: Dict[str, Any] = (
|
| 140 |
+
state.PROMPTS.get("system_prompts", {}) if isinstance(state.PROMPTS, dict) else {}
|
| 141 |
+
)
|
| 142 |
+
system_prompt = system_prompts.get("gerar_slides")
|
| 143 |
+
if not system_prompt:
|
| 144 |
+
raise RuntimeError("Prompt 'gerar_slides' não configurado em prompts.json")
|
| 145 |
+
|
| 146 |
+
user_payload = {
|
| 147 |
+
"ideas": ideas,
|
| 148 |
+
"title_hint": title,
|
| 149 |
+
"audience": audience,
|
| 150 |
+
"language": language or "pt",
|
| 151 |
+
"num_slides": num_slides,
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
if provider != "openai":
|
| 155 |
+
raise RuntimeError("Somente provider 'openai' é suportado para geração de slides.")
|
| 156 |
+
|
| 157 |
+
try:
|
| 158 |
+
from openai import OpenAI
|
| 159 |
+
except ImportError as exc:
|
| 160 |
+
raise RuntimeError("Biblioteca openai não instalada para geração de slides.") from exc
|
| 161 |
+
|
| 162 |
+
api_key = os.getenv("OPENAI_API_KEY")
|
| 163 |
+
if not api_key:
|
| 164 |
+
raise RuntimeError("Variável de ambiente OPENAI_API_KEY não configurada.")
|
| 165 |
+
|
| 166 |
+
client = OpenAI(api_key=api_key)
|
| 167 |
+
messages = [
|
| 168 |
+
{"role": "system", "content": system_prompt},
|
| 169 |
+
{
|
| 170 |
+
"role": "user",
|
| 171 |
+
"content": (
|
| 172 |
+
"Gere uma apresentação em JSON seguindo o formato especificado. "
|
| 173 |
+
"Aqui estão as ideias base e metadados em JSON:\n"
|
| 174 |
+
+ json.dumps(user_payload, ensure_ascii=False)
|
| 175 |
+
),
|
| 176 |
+
},
|
| 177 |
+
]
|
| 178 |
+
temperature_value = 0.4 if temperature is None else float(temperature)
|
| 179 |
+
resp = client.chat.completions.create(
|
| 180 |
+
model=model_name,
|
| 181 |
+
messages=messages,
|
| 182 |
+
temperature=temperature_value,
|
| 183 |
+
)
|
| 184 |
+
raw = resp.choices[0].message.content.strip()
|
| 185 |
+
|
| 186 |
+
try:
|
| 187 |
+
data = json.loads(raw)
|
| 188 |
+
except json.JSONDecodeError as exc:
|
| 189 |
+
raise RuntimeError(
|
| 190 |
+
f"Resposta do modelo não é JSON válido: {exc}. Conteúdo: {raw[:400]}"
|
| 191 |
+
) from exc
|
| 192 |
+
|
| 193 |
+
if not isinstance(data, dict) or "slides" not in data:
|
| 194 |
+
raise RuntimeError("JSON retornado não contém chave 'slides'.")
|
| 195 |
+
|
| 196 |
+
return data
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ---------------------------------------------------------------------------
|
| 200 |
+
# PPTX builder
|
| 201 |
+
# ---------------------------------------------------------------------------
|
| 202 |
+
|
| 203 |
+
def build_pptx_from_structure(structure: Dict[str, Any]) -> str:
|
| 204 |
+
"""Construye un archivo PPTX temporal y devuelve su ruta."""
|
| 205 |
+
try:
|
| 206 |
+
from pptx import Presentation
|
| 207 |
+
except ImportError as exc:
|
| 208 |
+
raise RuntimeError("Biblioteca python-pptx não instalada no ambiente.") from exc
|
| 209 |
+
|
| 210 |
+
pres = Presentation()
|
| 211 |
+
title = str(structure.get("title") or "Apresentação gerada pelo Chatbot NORM")
|
| 212 |
+
|
| 213 |
+
title_layout = pres.slide_layouts[0]
|
| 214 |
+
slide = pres.slides.add_slide(title_layout)
|
| 215 |
+
slide.shapes.title.text = title
|
| 216 |
+
if len(slide.placeholders) > 1:
|
| 217 |
+
slide.placeholders[1].text = (
|
| 218 |
+
"Gerado automaticamente a partir de ideias do Chatbot NORM"
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
slides_data = structure.get("slides") or []
|
| 222 |
+
if not isinstance(slides_data, list):
|
| 223 |
+
slides_data = []
|
| 224 |
+
|
| 225 |
+
for slide_data in slides_data:
|
| 226 |
+
layout = pres.slide_layouts[1]
|
| 227 |
+
s = pres.slides.add_slide(layout)
|
| 228 |
+
s.shapes.title.text = str(slide_data.get("title") or "")
|
| 229 |
+
|
| 230 |
+
body_placeholder = s.placeholders[1]
|
| 231 |
+
tf = body_placeholder.text_frame
|
| 232 |
+
while tf.paragraphs:
|
| 233 |
+
p = tf.paragraphs[0]
|
| 234 |
+
p.text = ""
|
| 235 |
+
if len(tf.paragraphs) == 1:
|
| 236 |
+
break
|
| 237 |
+
tf._element.remove(p._p)
|
| 238 |
+
|
| 239 |
+
bullets = slide_data.get("bullets") or []
|
| 240 |
+
if isinstance(bullets, list):
|
| 241 |
+
first = True
|
| 242 |
+
for bullet in bullets:
|
| 243 |
+
text = str(bullet)
|
| 244 |
+
if first:
|
| 245 |
+
tf.paragraphs[0].text = text
|
| 246 |
+
first = False
|
| 247 |
+
else:
|
| 248 |
+
p = tf.add_paragraph()
|
| 249 |
+
p.text = text
|
| 250 |
+
p.level = 0
|
| 251 |
+
|
| 252 |
+
notes = slide_data.get("notes")
|
| 253 |
+
if notes:
|
| 254 |
+
s.notes_slide.notes_text_frame.text = str(notes)
|
| 255 |
+
|
| 256 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".pptx") as tmp:
|
| 257 |
+
pres.save(tmp.name)
|
| 258 |
+
return tmp.name
|
app/services/rag.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Lógica principal del pipeline RAG:
|
| 3 |
+
- process_query(payload) → QueryResponse
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import re
|
| 7 |
+
from typing import Any, Dict, List, Set
|
| 8 |
+
|
| 9 |
+
import app.core.state as state
|
| 10 |
+
from app.core.state import embed_query
|
| 11 |
+
from app.models.requests import QueryRequest
|
| 12 |
+
from app.models.responses import QueryResponse
|
| 13 |
+
from app.services.helpers import (
|
| 14 |
+
build_chatbot_fallback_answer,
|
| 15 |
+
collect_metadata_chunks_for_docs,
|
| 16 |
+
contains_any_trigger,
|
| 17 |
+
detect_target_sections,
|
| 18 |
+
extract_explicit_target_doc_ids,
|
| 19 |
+
extract_quoted_titles,
|
| 20 |
+
get_documents_catalog,
|
| 21 |
+
get_topics_catalog_from_metadata,
|
| 22 |
+
list_documents_from_metadata,
|
| 23 |
+
normalize_match_text,
|
| 24 |
+
resolve_doc_ids_from_titles,
|
| 25 |
+
resolve_topic_from_question,
|
| 26 |
+
supplement_retrieval_for_target_docs,
|
| 27 |
+
)
|
| 28 |
+
from app.services.llm import call_llm
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def process_query(payload: QueryRequest) -> QueryResponse: # noqa: C901 – lógica RAG densa por diseño
|
| 32 |
+
top_k = payload.top_k or state.CONFIG.get("retrieve", {}).get("top_k", 4)
|
| 33 |
+
temperature = payload.temperature
|
| 34 |
+
mode = payload.mode
|
| 35 |
+
|
| 36 |
+
query_text = payload.question
|
| 37 |
+
effective_mode = mode
|
| 38 |
+
quoted_titles = extract_quoted_titles(payload.question)
|
| 39 |
+
target_doc_ids_for_filter: Set[str] = set()
|
| 40 |
+
catalog = get_documents_catalog()
|
| 41 |
+
explicit_target_doc_ids = extract_explicit_target_doc_ids(payload.question, catalog)
|
| 42 |
+
if explicit_target_doc_ids:
|
| 43 |
+
target_doc_ids_for_filter = set(explicit_target_doc_ids)
|
| 44 |
+
|
| 45 |
+
pregunta_lower = payload.question.lower().strip()
|
| 46 |
+
|
| 47 |
+
chatbot_min_top_k = int(state.CONFIG.get("retrieve", {}).get("chatbot_top_k", 16))
|
| 48 |
+
if mode == "chatbot":
|
| 49 |
+
top_k = max(top_k, chatbot_min_top_k)
|
| 50 |
+
|
| 51 |
+
# === 0. Triggers: ayuda, smalltalk, saludo ===
|
| 52 |
+
help_triggers = [str(t).lower() for t in state.TRIGGERS.get("help", [])]
|
| 53 |
+
smalltalk_triggers = [str(t).lower() for t in state.TRIGGERS.get("smalltalk", [])]
|
| 54 |
+
greeting_triggers = [str(t).lower() for t in state.TRIGGERS.get("greeting", [])]
|
| 55 |
+
|
| 56 |
+
if contains_any_trigger(pregunta_lower, help_triggers):
|
| 57 |
+
return QueryResponse(answer=state.HELP_SCOPE_TEXT, retrieved=[])
|
| 58 |
+
if contains_any_trigger(pregunta_lower, smalltalk_triggers):
|
| 59 |
+
return QueryResponse(answer=state.ABOUT_BOT_TEXT, retrieved=[])
|
| 60 |
+
if contains_any_trigger(pregunta_lower, greeting_triggers):
|
| 61 |
+
return QueryResponse(answer=state.GREETING_TEXT, retrieved=[])
|
| 62 |
+
|
| 63 |
+
# === 0.1. Intenciones de resumen / tabla ===
|
| 64 |
+
multi_summary = False
|
| 65 |
+
summary_keywords = ["resumo", "resumen", "summary", "resuma", "resumir"]
|
| 66 |
+
table_keywords = ["tabela", "tabla", "table", "gerar tabela", "gera uma tabela"]
|
| 67 |
+
summary_modes = {"summary", "summary_multi", "summary_sections", "summary_sections_multi"}
|
| 68 |
+
table_modes = {"table", "table_multi"}
|
| 69 |
+
multi_doc_markers = [
|
| 70 |
+
"esses documentos", "esses 5 documentos", "esses cinco documentos",
|
| 71 |
+
"esses 10 documentos", "esses dez documentos",
|
| 72 |
+
"documentos listados", "documentos acima", "documentos mostrados",
|
| 73 |
+
"os documentos", "los documentos", "all documents",
|
| 74 |
+
"todos", "todas", "all", "ambos", "both",
|
| 75 |
+
"os 3", "los 3", "3 documentos", "3 docs",
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
has_summary_intent = contains_any_trigger(pregunta_lower, summary_keywords)
|
| 79 |
+
has_table_intent = contains_any_trigger(pregunta_lower, table_keywords)
|
| 80 |
+
has_multi_doc_reference = contains_any_trigger(pregunta_lower, multi_doc_markers)
|
| 81 |
+
target_sections = detect_target_sections(pregunta_lower)
|
| 82 |
+
|
| 83 |
+
if has_summary_intent and has_multi_doc_reference and not state.LAST_LISTED_DOCS:
|
| 84 |
+
return QueryResponse(
|
| 85 |
+
answer=(
|
| 86 |
+
"Não encontrei uma lista recente de documentos no contexto atual. "
|
| 87 |
+
"Por favor, liste os documentos novamente e depois peça o resumo dos documentos listados."
|
| 88 |
+
),
|
| 89 |
+
retrieved=[],
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
if has_table_intent and has_multi_doc_reference:
|
| 93 |
+
docs_scope = state.LAST_LISTED_DOCS if state.LAST_LISTED_DOCS else catalog
|
| 94 |
+
if docs_scope:
|
| 95 |
+
effective_mode = "table_multi"
|
| 96 |
+
target_doc_ids_for_filter = {str(d.get("id")) for d in docs_scope if d.get("id")}
|
| 97 |
+
titulos = [str(d.get("title") or d.get("id")) for d in docs_scope if (d.get("title") or d.get("id"))]
|
| 98 |
+
titulos_str = "; ".join(titulos)
|
| 99 |
+
query_text = (
|
| 100 |
+
"Gere uma tabela comparativa, usando apenas os trechos fornecidos, para os documentos alvo abaixo. "
|
| 101 |
+
"Use colunas objetivas e inclua citações [n] nas células factuais relevantes. "
|
| 102 |
+
f"Documentos alvo ({len(titulos)}): {titulos_str}"
|
| 103 |
+
)
|
| 104 |
+
top_k = max(top_k, max(12, len(docs_scope) * 8))
|
| 105 |
+
else:
|
| 106 |
+
effective_mode = "table"
|
| 107 |
+
top_k = max(top_k, 12)
|
| 108 |
+
elif has_table_intent:
|
| 109 |
+
effective_mode = "table"
|
| 110 |
+
top_k = max(top_k, 12)
|
| 111 |
+
|
| 112 |
+
if state.LAST_LISTED_DOCS and has_summary_intent and has_multi_doc_reference and target_sections:
|
| 113 |
+
multi_summary = True
|
| 114 |
+
effective_mode = "summary_sections_multi"
|
| 115 |
+
docs_scope = state.LAST_LISTED_DOCS
|
| 116 |
+
target_doc_ids_for_filter = {str(d.get("id")) for d in docs_scope if d.get("id")}
|
| 117 |
+
titulos = [str(d.get("title") or d.get("id")) for d in docs_scope if (d.get("title") or d.get("id"))]
|
| 118 |
+
titulos_str = "; ".join(titulos)
|
| 119 |
+
sections_text = ", ".join(target_sections)
|
| 120 |
+
query_text = (
|
| 121 |
+
f"Resuma SOMENTE as seções '{sections_text}' de CADA documento listado abaixo, sem misturar conteúdos entre documentos. "
|
| 122 |
+
"Formato obrigatório: um bloco por documento com título. "
|
| 123 |
+
"Para cada seção solicitada, escreva minimo 150 palavras, com mais detalhe técnico. "
|
| 124 |
+
"Use citações [n] ao longo do texto. "
|
| 125 |
+
f"Documentos alvo ({len(titulos)}): {titulos_str}"
|
| 126 |
+
)
|
| 127 |
+
top_k = max(top_k, max(12, len(docs_scope) * 8))
|
| 128 |
+
|
| 129 |
+
elif state.LAST_LISTED_DOCS and has_summary_intent and has_multi_doc_reference:
|
| 130 |
+
multi_summary = True
|
| 131 |
+
effective_mode = "summary_multi"
|
| 132 |
+
docs_scope = state.LAST_LISTED_DOCS
|
| 133 |
+
target_doc_ids_for_filter = {str(d.get("id")) for d in docs_scope if d.get("id")}
|
| 134 |
+
titulos_list: List[str] = [
|
| 135 |
+
str(d.get("title") or d.get("id"))
|
| 136 |
+
for d in docs_scope
|
| 137 |
+
if (d.get("title") or d.get("id"))
|
| 138 |
+
]
|
| 139 |
+
titulos_str = "; ".join(titulos_list)
|
| 140 |
+
query_text = (
|
| 141 |
+
"Com base exclusivamente nos trechos dos documentos abaixo, "
|
| 142 |
+
"elabore um resumo integrado e sintético, destacando temas centrais, "
|
| 143 |
+
"pontos em comum e diferenças relevantes entre eles."
|
| 144 |
+
"IMPORTANTE: use citações no formato [n] ao longo do texto. Documentos: "
|
| 145 |
+
+ titulos_str
|
| 146 |
+
)
|
| 147 |
+
top_k = max(top_k, max(12, len(docs_scope) * 6))
|
| 148 |
+
|
| 149 |
+
# === Detección de pedido de listado de documentos ===
|
| 150 |
+
keywords_lista = [
|
| 151 |
+
"list", "lista", "listar", "mostrar", "exibir", "quais são",
|
| 152 |
+
"quais sao", "que documentos", "documentos disponíveis",
|
| 153 |
+
"documentos disponiveis", "documentos indexados",
|
| 154 |
+
]
|
| 155 |
+
doc_words = ["documento", "documentos", "documents"]
|
| 156 |
+
topic_words = ["topic", "topico", "tópico", "tema"]
|
| 157 |
+
topics_catalog = get_topics_catalog_from_metadata()
|
| 158 |
+
requested_topic = resolve_topic_from_question(payload.question, topics_catalog)
|
| 159 |
+
has_topic_reference = contains_any_trigger(pregunta_lower, topic_words) or bool(requested_topic)
|
| 160 |
+
|
| 161 |
+
is_list_request = (
|
| 162 |
+
(not multi_summary)
|
| 163 |
+
and (not has_table_intent)
|
| 164 |
+
and contains_any_trigger(pregunta_lower, keywords_lista)
|
| 165 |
+
and contains_any_trigger(pregunta_lower, doc_words)
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
requested_n: int | None = None
|
| 169 |
+
num_match = re.search(r"\b(\d{1,3})\b", pregunta_lower)
|
| 170 |
+
if num_match:
|
| 171 |
+
try:
|
| 172 |
+
requested_n = int(num_match.group(1))
|
| 173 |
+
except ValueError:
|
| 174 |
+
requested_n = None
|
| 175 |
+
|
| 176 |
+
if is_list_request:
|
| 177 |
+
if has_topic_reference and requested_topic:
|
| 178 |
+
documentos = list_documents_from_metadata(topic=requested_topic)
|
| 179 |
+
else:
|
| 180 |
+
if hasattr(state.RETRIEVER, "list_documents"):
|
| 181 |
+
documentos = state.RETRIEVER.list_documents()
|
| 182 |
+
else:
|
| 183 |
+
documentos = list_documents_from_metadata()
|
| 184 |
+
|
| 185 |
+
total = len(documentos)
|
| 186 |
+
limite = max(1, min(requested_n, total)) if requested_n is not None else total
|
| 187 |
+
documentos_mostrados = documentos[:limite]
|
| 188 |
+
|
| 189 |
+
if has_topic_reference and requested_topic:
|
| 190 |
+
lista_formatada = "\n".join([
|
| 191 |
+
f"{i+1}. {doc.get('title', doc.get('id', 'Desconhecido'))}"
|
| 192 |
+
for i, doc in enumerate(documentos_mostrados)
|
| 193 |
+
])
|
| 194 |
+
resposta = (
|
| 195 |
+
f"📚 **Documentos do tópico '{requested_topic}' ({limite} de {total} no total):**\n\n"
|
| 196 |
+
+ lista_formatada
|
| 197 |
+
)
|
| 198 |
+
else:
|
| 199 |
+
lista_formatada = "\n".join([
|
| 200 |
+
f"{i+1}. {doc.get('title', doc.get('id', 'Desconhecido'))}"
|
| 201 |
+
for i, doc in enumerate(documentos_mostrados)
|
| 202 |
+
])
|
| 203 |
+
resposta = (
|
| 204 |
+
f"📚 **Documentos indexados no sistema ({limite} de {total} no total):**\n\n"
|
| 205 |
+
+ lista_formatada
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
resposta = re.sub(r"\s*</div>\s*$", "", resposta, flags=re.IGNORECASE)
|
| 209 |
+
state.LAST_LISTED_DOCS = documentos_mostrados
|
| 210 |
+
return QueryResponse(answer=resposta, retrieved=[])
|
| 211 |
+
|
| 212 |
+
# === Resolución de scope para resúmenes y tablas ===
|
| 213 |
+
is_summary_request = (effective_mode in summary_modes) or has_summary_intent
|
| 214 |
+
is_table_request = (effective_mode in table_modes) or has_table_intent
|
| 215 |
+
|
| 216 |
+
if is_summary_request or is_table_request:
|
| 217 |
+
resolved_from_titles = (
|
| 218 |
+
resolve_doc_ids_from_titles(quoted_titles, catalog) if quoted_titles else set()
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
if effective_mode in {"summary_sections", "summary_sections_multi"} and target_sections:
|
| 222 |
+
sections_text = ", ".join(target_sections)
|
| 223 |
+
query_text = f"{query_text}\n\nSeções solicitadas: {sections_text}."
|
| 224 |
+
|
| 225 |
+
if explicit_target_doc_ids:
|
| 226 |
+
target_doc_ids_for_filter = set(explicit_target_doc_ids)
|
| 227 |
+
elif resolved_from_titles:
|
| 228 |
+
target_doc_ids_for_filter = set(resolved_from_titles)
|
| 229 |
+
elif (
|
| 230 |
+
effective_mode in {"summary_multi", "summary_sections_multi", "table_multi"}
|
| 231 |
+
and state.LAST_LISTED_DOCS
|
| 232 |
+
and not target_doc_ids_for_filter
|
| 233 |
+
):
|
| 234 |
+
target_doc_ids_for_filter = {
|
| 235 |
+
str(d.get("id")) for d in state.LAST_LISTED_DOCS if d.get("id")
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
if is_table_request and quoted_titles and not resolved_from_titles:
|
| 239 |
+
return QueryResponse(
|
| 240 |
+
answer=(
|
| 241 |
+
"Não há informações suficientes na base. "
|
| 242 |
+
"Não foi possível localizar exatamente o documento solicitado para gerar a tabela."
|
| 243 |
+
),
|
| 244 |
+
retrieved=[],
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
if target_doc_ids_for_filter:
|
| 248 |
+
top_k = max(top_k, max(12, len(target_doc_ids_for_filter) * 8))
|
| 249 |
+
elif quoted_titles:
|
| 250 |
+
query_text = (
|
| 251 |
+
f"{payload.question}\n\n"
|
| 252 |
+
f"Título alvo (priorizar este documento): {'; '.join(quoted_titles)}"
|
| 253 |
+
)
|
| 254 |
+
top_k = max(top_k, 12)
|
| 255 |
+
|
| 256 |
+
# === 1. Embed y búsqueda ===
|
| 257 |
+
index_type = state.CONFIG.get("index", {}).get("type", "faiss").lower()
|
| 258 |
+
normalize = index_type == "faiss"
|
| 259 |
+
q_vec = embed_query(state.EMBED_MODEL, query_text, normalize=normalize)
|
| 260 |
+
retrieved: List[Dict[str, Any]] = state.RETRIEVER.retrieve(q_vec, top_k)
|
| 261 |
+
|
| 262 |
+
if len(target_doc_ids_for_filter) == 1 and is_summary_request:
|
| 263 |
+
direct_doc_chunks = collect_metadata_chunks_for_docs(
|
| 264 |
+
metadata=state.METADATA,
|
| 265 |
+
target_doc_ids=target_doc_ids_for_filter,
|
| 266 |
+
max_chunks_per_doc=20,
|
| 267 |
+
)
|
| 268 |
+
if direct_doc_chunks:
|
| 269 |
+
retrieved = direct_doc_chunks
|
| 270 |
+
|
| 271 |
+
if target_doc_ids_for_filter:
|
| 272 |
+
scoped = [
|
| 273 |
+
item for item in retrieved
|
| 274 |
+
if str(item.get("document_id", "")) in target_doc_ids_for_filter
|
| 275 |
+
]
|
| 276 |
+
retrieved = scoped
|
| 277 |
+
chunks_per_doc = 6
|
| 278 |
+
retrieved = supplement_retrieval_for_target_docs(
|
| 279 |
+
retrieved=retrieved,
|
| 280 |
+
target_doc_ids=target_doc_ids_for_filter,
|
| 281 |
+
metadata=state.METADATA,
|
| 282 |
+
chunks_per_doc=chunks_per_doc,
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
if quoted_titles and retrieved and not target_doc_ids_for_filter:
|
| 286 |
+
target_titles_norm = [
|
| 287 |
+
normalize_match_text(t) for t in quoted_titles if normalize_match_text(t)
|
| 288 |
+
]
|
| 289 |
+
|
| 290 |
+
def _title_score(item: Dict[str, Any]) -> int:
|
| 291 |
+
title_norm = normalize_match_text(item.get("document_title") or "")
|
| 292 |
+
if not title_norm:
|
| 293 |
+
return 0
|
| 294 |
+
score = 0
|
| 295 |
+
for target in target_titles_norm:
|
| 296 |
+
if title_norm == target:
|
| 297 |
+
score = max(score, 3)
|
| 298 |
+
elif target in title_norm:
|
| 299 |
+
score = max(score, 2)
|
| 300 |
+
elif title_norm in target and len(title_norm) >= 20:
|
| 301 |
+
score = max(score, 1)
|
| 302 |
+
return score
|
| 303 |
+
|
| 304 |
+
scored_items = [(item, _title_score(item)) for item in retrieved]
|
| 305 |
+
best_score = max(score for _, score in scored_items)
|
| 306 |
+
|
| 307 |
+
if best_score > 0:
|
| 308 |
+
best_item = next(item for item, score in scored_items if score == best_score)
|
| 309 |
+
best_doc_id = best_item.get("document_id")
|
| 310 |
+
if best_doc_id:
|
| 311 |
+
retrieved = [item for item, _ in scored_items if item.get("document_id") == best_doc_id]
|
| 312 |
+
else:
|
| 313 |
+
retrieved = [item for item, score in scored_items if score == best_score]
|
| 314 |
+
|
| 315 |
+
# === 2. Asignar citation_id temporal ===
|
| 316 |
+
citation_ids: Dict[str, int] = {}
|
| 317 |
+
next_id = 1
|
| 318 |
+
for m in retrieved:
|
| 319 |
+
doc_id = m.get("document_id")
|
| 320 |
+
if not doc_id:
|
| 321 |
+
continue
|
| 322 |
+
if doc_id not in citation_ids:
|
| 323 |
+
citation_ids[doc_id] = next_id
|
| 324 |
+
next_id += 1
|
| 325 |
+
m["citation_id"] = citation_ids[doc_id]
|
| 326 |
+
|
| 327 |
+
# === DEBUG ===
|
| 328 |
+
print("\n[DEBUG] ===== CHUNKS ENVIADOS AL MODELO =====")
|
| 329 |
+
print(f"[DEBUG] question: {payload.question}")
|
| 330 |
+
print(f"[DEBUG] mode: {effective_mode}")
|
| 331 |
+
print(f"[DEBUG] total_chunks: {len(retrieved)}")
|
| 332 |
+
for i, ch in enumerate(retrieved, start=1):
|
| 333 |
+
from app.services.helpers import normalizar_texto
|
| 334 |
+
doc_id = ch.get("document_id", "N/A")
|
| 335 |
+
doc_title = ch.get("document_title", "N/A")
|
| 336 |
+
frag_id = ch.get("fragment_id", ch.get("idx", "N/A"))
|
| 337 |
+
content = normalizar_texto(ch.get("content", ""))
|
| 338 |
+
preview = content[:220] + ("..." if len(content) > 220 else "")
|
| 339 |
+
print(
|
| 340 |
+
f"[DEBUG] #{i} | doc_id={doc_id} | frag={frag_id} | title={doc_title}\n"
|
| 341 |
+
f" {preview}"
|
| 342 |
+
)
|
| 343 |
+
print("[DEBUG] =====================================\n")
|
| 344 |
+
|
| 345 |
+
# === 4. Llamar al LLM ===
|
| 346 |
+
answer = call_llm(query_text, retrieved, temperature, effective_mode)
|
| 347 |
+
|
| 348 |
+
# === 5. Detectar tag especial <<LIST_DOCUMENTS>> ===
|
| 349 |
+
if answer.strip() == "<<LIST_DOCUMENTS>>":
|
| 350 |
+
if is_list_request:
|
| 351 |
+
if hasattr(state.RETRIEVER, "list_documents"):
|
| 352 |
+
documentos = state.RETRIEVER.list_documents()
|
| 353 |
+
else:
|
| 354 |
+
documentos = list_documents_from_metadata()
|
| 355 |
+
total = len(documentos)
|
| 356 |
+
limite = max(1, min(requested_n, total)) if requested_n is not None else total
|
| 357 |
+
documentos_mostrados = documentos[:limite]
|
| 358 |
+
lista_formatada = "\n".join([
|
| 359 |
+
f"{i+1}. {doc.get('title', doc.get('id', 'Desconhecido'))}"
|
| 360 |
+
for i, doc in enumerate(documentos_mostrados)
|
| 361 |
+
])
|
| 362 |
+
resposta = (
|
| 363 |
+
f"📚 **Documentos indexados no sistema ({limite} de {total} no total):**\n\n"
|
| 364 |
+
+ lista_formatada
|
| 365 |
+
)
|
| 366 |
+
resposta = re.sub(r"\s*</div>\s*$", "", resposta, flags=re.IGNORECASE)
|
| 367 |
+
state.LAST_LISTED_DOCS = documentos_mostrados
|
| 368 |
+
return QueryResponse(answer=resposta, retrieved=[])
|
| 369 |
+
else:
|
| 370 |
+
answer = state.NO_INFO_PREFIX
|
| 371 |
+
|
| 372 |
+
# === 6. Sin información ===
|
| 373 |
+
if answer.strip().startswith(state.NO_INFO_PREFIX) or answer.strip().startswith(
|
| 374 |
+
"Desculpe, mas não tenho informações"
|
| 375 |
+
):
|
| 376 |
+
if effective_mode == "chatbot" and retrieved:
|
| 377 |
+
stripped = answer.strip()
|
| 378 |
+
if stripped.startswith(state.NO_INFO_PREFIX):
|
| 379 |
+
suffix = stripped[len(state.NO_INFO_PREFIX):].lstrip(" .:-\n\t")
|
| 380 |
+
answer = (suffix[0].upper() + suffix[1:]) if suffix else build_chatbot_fallback_answer(retrieved)
|
| 381 |
+
else:
|
| 382 |
+
answer = build_chatbot_fallback_answer(retrieved)
|
| 383 |
+
else:
|
| 384 |
+
return QueryResponse(answer=answer, retrieved=[])
|
| 385 |
+
|
| 386 |
+
if effective_mode == "chatbot" and retrieved and not answer.strip():
|
| 387 |
+
answer = build_chatbot_fallback_answer(retrieved)
|
| 388 |
+
|
| 389 |
+
if answer.strip().startswith(state.NO_INFO_PREFIX) or answer.strip().startswith(
|
| 390 |
+
"Desculpe, mas não tenho informações"
|
| 391 |
+
):
|
| 392 |
+
return QueryResponse(answer=answer, retrieved=[])
|
| 393 |
+
|
| 394 |
+
# === 7. Filtro de saludos genéricos ===
|
| 395 |
+
saludo_regex = r"^(ol[áa]|hola|hello|hi)[!,.]?"
|
| 396 |
+
generic_prefixes = [
|
| 397 |
+
"eu sou um assistente",
|
| 398 |
+
"sou um assistente",
|
| 399 |
+
"sou um assistente virtual",
|
| 400 |
+
"eu sou um assistente virtual",
|
| 401 |
+
]
|
| 402 |
+
if re.match(saludo_regex, answer.strip().lower()) or any(
|
| 403 |
+
answer.strip().lower().startswith(p) for p in generic_prefixes
|
| 404 |
+
):
|
| 405 |
+
return QueryResponse(answer=answer, retrieved=[])
|
| 406 |
+
|
| 407 |
+
# === 8. Renumeración de citas ===
|
| 408 |
+
raw_citations = [int(m.group(1)) for m in re.finditer(r"\[(\d+)\]", answer)]
|
| 409 |
+
if raw_citations:
|
| 410 |
+
unique_old_numbers = list(dict.fromkeys(raw_citations))
|
| 411 |
+
renumber_map = {old: new for new, old in enumerate(unique_old_numbers, start=1)}
|
| 412 |
+
|
| 413 |
+
for old, new in renumber_map.items():
|
| 414 |
+
answer = answer.replace(f"[{old}]", f"[TEMP_{new}]")
|
| 415 |
+
answer = answer.replace("TEMP_", "")
|
| 416 |
+
answer = re.sub(r"\[(\d+)\](\s*\[\1\])+", r"[\1]", answer)
|
| 417 |
+
|
| 418 |
+
for m in retrieved:
|
| 419 |
+
old_id = m.get("citation_id")
|
| 420 |
+
if old_id in renumber_map:
|
| 421 |
+
m["citation_id"] = renumber_map[old_id]
|
| 422 |
+
else:
|
| 423 |
+
m["citation_id"] = None
|
| 424 |
+
|
| 425 |
+
retrieved = [m for m in retrieved if m.get("citation_id") is not None]
|
| 426 |
+
else:
|
| 427 |
+
retrieved = []
|
| 428 |
+
|
| 429 |
+
answer = re.sub(r"\s*</div>\s*$", "", answer, flags=re.IGNORECASE)
|
| 430 |
+
return QueryResponse(answer=answer, retrieved=retrieved)
|
configs/config.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"embeddings": {
|
| 3 |
+
"model_name": "intfloat/multilingual-e5-large",
|
| 4 |
+
"output_dir": "embeddings2",
|
| 5 |
+
"provider": "hf",
|
| 6 |
+
"max_files_for_debug": 800,
|
| 7 |
+
"shuffle_sample": true,
|
| 8 |
+
"random_seed": 42,
|
| 9 |
+
"require_frontmatter": true,
|
| 10 |
+
"priority_max": 700,
|
| 11 |
+
"priority_keywords": [
|
| 12 |
+
"\\bb[aá]rio\\b",
|
| 13 |
+
"\\bbarium\\b",
|
| 14 |
+
"\\bNORM\\b",
|
| 15 |
+
"naturally occurring radioactive",
|
| 16 |
+
"TENORM",
|
| 17 |
+
"226Ra",
|
| 18 |
+
"228Ra",
|
| 19 |
+
"Ra-226",
|
| 20 |
+
"Ra-228",
|
| 21 |
+
"sedimento[s]? marino[s]?",
|
| 22 |
+
"marine sediment",
|
| 23 |
+
"sediment[oa]s? de mar"
|
| 24 |
+
]
|
| 25 |
+
},
|
| 26 |
+
"splitter": {
|
| 27 |
+
"type": "tokens",
|
| 28 |
+
"chunk_size": 450,
|
| 29 |
+
"overlap": 90
|
| 30 |
+
},
|
| 31 |
+
"retrieve": {
|
| 32 |
+
"top_k": 12,
|
| 33 |
+
"chatbot_top_k": 20
|
| 34 |
+
},
|
| 35 |
+
"paths": {
|
| 36 |
+
"input_path": "Docs/*.md",
|
| 37 |
+
"chunks_path": "data/chunks",
|
| 38 |
+
"embeddings_dir": "data/embeddings",
|
| 39 |
+
"index_dir": "data/index"
|
| 40 |
+
},
|
| 41 |
+
"index": {
|
| 42 |
+
"type": "faiss",
|
| 43 |
+
"index_file": "data/index/intfloat/multilingual-e5-large/faiss.index",
|
| 44 |
+
"metadata_file": "data/index/intfloat/multilingual-e5-large/metadata.jsonl",
|
| 45 |
+
"host": "https://my-elasticsearch-project-c6a2f9.es.us-central1.gcp.elastic.cloud:443",
|
| 46 |
+
"index_name": "chatbot-norm",
|
| 47 |
+
"vector_field": "embedding",
|
| 48 |
+
"api_key": null
|
| 49 |
+
},
|
| 50 |
+
"llm": {
|
| 51 |
+
"provider": "openai",
|
| 52 |
+
"model": "gpt-5.1",
|
| 53 |
+
"system_prompt": "Você é um assistente especialista em química e Naturally Occurring Radioactive Materials (NORM). Responde somente com informações explícitas dos trechos de contexto. Não faça inferências, não deduza, não invente informações. Regras: - Use exclusivamente os trechos como fonte. - Quando citar fontes, use o formato [n], onde n corresponde ao número da referência na tabela de referências fornecida. - Não invente regulações, datas ou valores que não estejam presentes. - Se a pergunta mencionar anos posteriores a 2025, responda que não há dados disponíveis. - Explique termos técnicos somente se aparecerem no contexto. - Toda frase que derive de um trecho do contexto DEVE ter uma citação obrigatória no formato [n]. Se você não adicionar essas citações, sua resposta está incorreta."
|
| 54 |
+
},
|
| 55 |
+
"ui": {
|
| 56 |
+
"api_url": "http://127.0.0.1:8000/query"
|
| 57 |
+
}
|
| 58 |
+
}
|
configs/prompts.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"system_prompts": {
|
| 3 |
+
"summary": "Você é um assistente especializado em criar resumos técnicos baseados exclusivamente nos trechos de contexto fornecidos. Você não deve adicionar informações que não apareçam explicitamente nos trechos. Regras: - Use apenas as informações do contexto. - Não invente dados nem interprete além do que está escrito. - Organize o resumo em 2 a 3 parágrafos curtos, seguidos de 3 a 5 bullet points com os pontos-chave mais importantes. - Se o usuário pedir um tipo de resumo (bullet points, curto, longo, técnico), adapte-se mantendo sempre a fidelidade ao contexto. - Se o contexto for insuficiente para responder de forma confiável, comece a resposta exatamente com: \"Não há informações suficientes na base.\" e, em seguida, explique em 1 ou 2 frases por que as informações são insuficientes. - Não responda perguntas que não sejam resumos. - Quando citar fontes, use o formato [n], onde n corresponde ao número da referência na tabela de referências fornecida, mas cite preferencialmente ao final do resumo.",
|
| 4 |
+
"summary_sections": "Você é um assistente especializado em resumir documentos técnicos por seções específicas. Sua tarefa é produzir um resumo estruturado SOMENTE da(s) seção(ões) solicitada(s) pelo usuário, que podem incluir: Introdução, Metodologia e/ou Resultados, usando exclusivamente os trechos de contexto fornecidos. Regras: - Use apenas as informações do contexto; não invente dados, não extrapole e não inclua interpretação além do texto. - Responda somente com a(s) seção(ões) solicitada(s) pelo usuário. - Para cada seção solicitada, use exatamente o título da seção: '### <Seção>'. - Escreva de 1 a 2 parágrafos longos por seção (ou 4 a 8 bullet points, se o usuário pedir formato em lista), mantendo linguagem técnica clara e objetiva. - Para cada seção solicitada, escreva minimo 150 palavras, com mais detalhe técnico. - Se alguma seção solicitada não estiver presente no contexto, escreva: 'Seção não encontrada no contexto fornecido.' apenas para aquela seção. - Cite fontes, usando o formato [n], onde n corresponde ao número da referência na tabela fornecida, associando cada afirmação factual relevante a pelo menos uma citação [n]. - Não responda perguntas fora da tarefa de resumo de seção.",
|
| 5 |
+
"summary_sections_multi": "Você é um assistente técnico especializado em resumir documentos por seção específica. Tarefa: resumir SOMENTE a(s) seção(ões) solicitada(s) (Introdução, Metodologia e/ou Resultados) para CADA documento alvo separadamente, usando exclusivamente os trechos fornecidos. Regras: - Não misture conteúdo entre documentos. - Produza um bloco por documento, com o formato: '### Documento: <título>' e, dentro dele, uma subseção para cada seção solicitada usando '### <Seção>'. - Escreva 1 a 2 parágrafos longos por seção (ou 4 a 8 bullet points, se o usuário pedir formato em lista). - Se faltar evidência para um documento ou se alguma seção solicitada não estiver presente no contexto, escreva: 'Seção não encontrada no contexto fornecido.' para o documento/seção correspondente. - Não invente dados, não extrapole e não inclua interpretações além do texto. - Quando citar fontes, use o formato [n], onde n corresponde ao número da referência na tabela fornecida, associando cada afirmação factual relevante a pelo menos uma citação [n]. - Não responda fora da tarefa de resumo por seção em múltiplos documentos.",
|
| 6 |
+
"table": "Você é um assistente técnico especializado em gerar tabelas de evidência técnica de um único documento, usando exclusivamente os trechos de contexto fornecidos. Regras obrigatórias: - Use somente informações explícitas do contexto. - Não invente dados, não extrapole e não use conhecimento externo. - Responda prioritariamente em tabela Markdown. - Cada linha factual deve ter pelo menos uma citação no formato [n]. - Se um campo não estiver disponível no contexto, preencha com N/D. - Se não houver evidência técnica suficiente para montar a tabela, comece exatamente com: Não há informações suficientes na base. e explique em 1-2 frases. Escopo técnico (prioridade máxima): - Extraia informações principalmente de conteúdos equivalentes a: Introdução, Metodologia e Resultados. - Foque em: objetivo do estudo, desenho/metodologia, amostra/matriz, radionuclídeos, técnicas analíticas, faixas/valores de atividade, principais achados, limitações e implicações. Restrições de relevância: - Não incluir metadados bibliográficos (título, autores, ano, periódico, DOI, afiliação, data de publicação), a menos que o usuário peça explicitamente. - Se o usuário pedir tabela sobre um tema específico, inclua apenas linhas relacionadas a esse tema e ignore o restante.",
|
| 7 |
+
"table_multi": "Você é um assistente técnico especializado em gerar tabelas comparativas para múltiplos documentos usando exclusivamente os trechos de contexto fornecidos. Regras: - Gere uma tabela Markdown comparativa, com uma linha por documento quando possível. - Não misture fatos entre documentos e não invente dados. - Inclua citações [n] nas células factuais relevantes. - Se um documento não tiver dados para algum campo, use 'N/D' nessa célula. - Se houver dados para parte dos documentos, produza a melhor tabela possível com os dados disponíveis e indique brevemente limitações ao final. Gere a tabela em formato Markdown, mas NÃO a coloque dentro de blocos de código. Escopo técnico (prioridade máxima): - Extraia informações principalmente de conteúdos equivalentes a: Introdução, Metodologia e Resultados. - Foque em: objetivo do estudo, desenho/metodologia, amostra/matriz, radionuclídeos, técnicas analíticas, faixas/valores de atividade, principais achados, limitações e implicações. Restrições de relevância: - Não incluir metadados bibliográficos (título, autores, ano, periódico, DOI, afiliação, data de publicação), a menos que o usuário peça explicitamente. - Se o usuário pedir tabela sobre um tema específico, inclua apenas linhas relacionadas a esse tema e ignore o restante.",
|
| 8 |
+
"summary_multi": "Você é um assistente especializado em produzir um resumo integrado de vários documentos técnicos sobre química e NORM. Use exclusivamente os trechos de contexto fornecidos, mas considere que eles podem não cobrir TODOS os detalhes de cada documento individual. Regras: - Construa um resumo conjunto em 2 a 3 parágrafos, seguido de 3 a 5 bullet points, destacando temas centrais, pontos em comum e diferenças relevantes entre os documentos. - Evite inventar dados; use apenas o que aparece nos trechos, mas é aceitável que o resumo não cubra exaustivamente todos os aspectos de cada documento. - Não diga que \"não há informações suficientes\" se houver trechos relevantes para pelo menos parte dos documentos; em vez disso, faça o melhor resumo possível com o material disponível e, se necessário, mencione brevemente que algumas informações podem estar incompletas. - Quando citar fontes, use o formato [n], onde n corresponde ao número da referência na tabela de referências fornecida, e garanta que cada afirmação factual relevante esteja associada a pelo menos uma citação [n].",
|
| 9 |
+
"chatbot": "Você é um assistente especialista em química e Naturally Occurring Radioactive Materials (NORM). Responde somente com informações explícitas dos trechos de contexto fornecidos. Não faça inferências, não deduza, não invente informações. Regras: - Use exclusivamente os trechos como fonte. - Estruture as respostas em parágrafos claros (introdução, explicação principal e, quando fizer sentido, conclusão), podendo incluir bullet points para listar etapas, propriedades ou recomendações, sempre baseados no contexto. - Explique termos técnicos em linguagem acessível sempre que houver informação suficiente no contexto para isso. - Quando citar fontes, use o formato [n], onde n corresponde ao número da referência na tabela de referências fornecida, e garanta que cada afirmação factual relevante esteja associada a pelo menos uma citação [n]. - Não invente regulações, datas ou valores que não estejam presentes. - Se a pergunta mencionar anos posteriores a 2026, responda claramente que não há dados disponíveis na base para esses anos e não tente extrapolar. - Se houver informação parcial relevante no contexto: - Responda utilizando apenas essa informação disponível. - Não complete lacunas com conhecimento externo. - Se NÃO houver nenhuma informação relevante no contexto: - Comece a resposta exatamente com: \"Não há informações suficientes na base.\" - Em seguida, explique em 1 ou 2 frases o que falta ou quais limites da base levam a essa conclusão.",
|
| 10 |
+
"gerar_ideias": "Você é um gerador especialista de ideias criativas, estratégicas e inovadoras. Sua função é produzir ideias originais, diferenciadas e acionáveis, baseadas exclusivamente na solicitação do usuário. Princípios a seguir: Priorize o pensamento divergente em vez do convencional. Evite ideias genéricas ou comuns. Combine disciplinas sempre que possível. Mantenha um equilíbrio entre criatividade e implementação realista (a menos que o usuário solicite ideias futuristas ou radicais). As ideias devem ser claras, concretas e explicadas de forma breve. Não repita padrões óbvios nem reformule a mesma ideia com variações mínimas. Caso o usuário não especifique restrições, assuma uma liberdade criativa moderada. Se a solicitação for ambígua, gere múltiplas abordagens possíveis. Se o tema for amplo, cubra diferentes ângulos estratégicos. Se o usuário solicitar muitas ideias, aumente progressivamente o nível de originalidade. Formato padrão de saída (a menos que o usuário indique outro): Para cada ideia, inclua: -Nome breve. -Descrição clara. -Elemento inovador. -Potencial aplicação ou impacto.",
|
| 11 |
+
"gerar_slides": "Você é um assistente especialista em design instrucional e apresentações. Sua função é transformar uma lista de ideias em um esqueleto de apresentação em slides, sem gerar o arquivo final, apenas uma estrutura em JSON. Regras gerais: - Use exclusivamente as ideias fornecidas pelo usuário (não invente novos tópicos centrais). - Organize a apresentação com um título geral, seções lógicas e slides numerados. - Para cada slide, defina: título curto, 3 a 6 bullet points e, opcionalmente, notas do apresentador com breves orientações de fala. - Adapte o nível de detalhe ao público-alvo e ao objetivo indicados pelo usuário, quando fornecidos. - Evite texto excessivamente longo em um único bullet; prefira frases curtas e claras. - Não inclua marcação Markdown, apenas texto simples. - Saída obrigatoriamente em JSON válido, com o seguinte formato: {{ \"title\": \"Título da apresentação\", \"slides\": [ {{ \"title\": \"Título do slide 1\", \"bullets\": [\"ponto 1\", \"ponto 2\"], \"notes\": \"Notas breves para o apresentador (opcional)\" }} ] }}. Não inclua nenhum texto fora do JSON."
|
| 12 |
+
},
|
| 13 |
+
"meta": {
|
| 14 |
+
"no_information_prefix": "Não há informações suficientes na base.",
|
| 15 |
+
"about_bot": "Sou o Chatbot NORM, um assistente especializado em química e materiais radioativos de ocorrência natural (NORM). Respondo com base nos documentos técnicos indexados na base de conhecimento deste sistema.",
|
| 16 |
+
"greeting": "Olá! Sou o Chatbot NORM, um assistente treinado para responder perguntas sobre química e NORM usando os documentos técnicos indexados. Você pode, por exemplo, pedir um resumo de um documento específico ou perguntar sobre um isótopo radioativo.",
|
| 17 |
+
"help_scope": "Posso ajudar respondendo perguntas sobre química, radioatividade natural (NORM), isótopos específicos e documentos técnicos indexados neste sistema. Faça perguntas objetivas e, se possível, mencione o tema ou documento de interesse para eu recuperar os trechos mais relevantes.",
|
| 18 |
+
"offtopic_default": "Minha função é responder apenas com base nos documentos técnicos de química e NORM que foram indexados. Para outros assuntos fora desse domínio, posso não ter informações suficientes na base.",
|
| 19 |
+
"goodbye": "Foi um prazer ajudar. Se precisar de mais alguma coisa sobre química ou NORM, é só perguntar novamente.",
|
| 20 |
+
"triggers": {
|
| 21 |
+
"greeting": [
|
| 22 |
+
"olá", "ola", "hola", "hello", "hi", "bom dia",
|
| 23 |
+
"boa tarde", "boa noite"
|
| 24 |
+
],
|
| 25 |
+
"smalltalk": [
|
| 26 |
+
"quem é vc", "quem e vc", "quem é voce", "quem e voce",
|
| 27 |
+
"quem é você", "quem és tu", "quien eres", "quien es usted"
|
| 28 |
+
],
|
| 29 |
+
"help": [
|
| 30 |
+
"em que pode me ajudar", "em que podemos ajudar",
|
| 31 |
+
"em que podeme ajudar",
|
| 32 |
+
"em que vc pode me ajudar", "em que voce pode me ajudar",
|
| 33 |
+
"em que você pode me ajudar", "em que voce pode ajudar",
|
| 34 |
+
"em que você pode ajudar", "que tipo de pergunta",
|
| 35 |
+
"que tipo de perguntas", "tipo de perguntas o bot",
|
| 36 |
+
"tipo de perguntas o chatbot"
|
| 37 |
+
]
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
}
|
pyproject.toml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=61"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "chatbot-norm"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
requires-python = ">=3.9"
|
| 9 |
+
dependencies = []
|
| 10 |
+
|
| 11 |
+
[tool.setuptools]
|
| 12 |
+
packages = [
|
| 13 |
+
"utils",
|
| 14 |
+
"app",
|
| 15 |
+
"app.core",
|
| 16 |
+
"app.models",
|
| 17 |
+
"app.services",
|
| 18 |
+
"app.routers",
|
| 19 |
+
]
|
requirements.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch==2.3.0
|
| 2 |
+
langchain_text_splitters==0.2.2
|
| 3 |
+
sentence-transformers==3.0.1
|
| 4 |
+
langchain_experimental==0.0.64
|
| 5 |
+
tiktoken==0.7.0
|
| 6 |
+
langchain-openai==0.1.14
|
| 7 |
+
langchain-core==0.2.33
|
| 8 |
+
langchain==0.2.14
|
| 9 |
+
pandas==2.3.3
|
| 10 |
+
tables==3.9.2
|
| 11 |
+
fastapi==0.115.0
|
| 12 |
+
uvicorn[standard]==0.30.1
|
| 13 |
+
faiss-cpu==1.8.0.post1
|
| 14 |
+
numpy==1.26.4
|
| 15 |
+
openai>=1.32.0,<2.0.0
|
| 16 |
+
elasticsearch>=8.0.0,<9.0.0
|
| 17 |
+
python-docx==1.2.0
|
| 18 |
+
markitdown[all]
|
| 19 |
+
python-multipart==0.0.22
|
| 20 |
+
python-pptx==0.6.23
|
scripts/build_index.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import shutil
|
| 4 |
+
import numpy as np
|
| 5 |
+
import faiss
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 9 |
+
if ROOT not in sys.path:
|
| 10 |
+
sys.path.insert(0, ROOT)
|
| 11 |
+
from utils import base_utils as bu
|
| 12 |
+
|
| 13 |
+
def build_faiss_index(config_path: str = "configs/config.json") -> None:
|
| 14 |
+
config = bu.load_config(config_path)
|
| 15 |
+
|
| 16 |
+
embeddings_dir = config["paths"].get("embeddings_dir", "data/embeddings")
|
| 17 |
+
index_dir = config["paths"].get("index_dir", "data/index")
|
| 18 |
+
|
| 19 |
+
embeddings_path = os.path.join(embeddings_dir, "embeddings.npy")
|
| 20 |
+
metadata_path = os.path.join(embeddings_dir, "metadata.jsonl")
|
| 21 |
+
|
| 22 |
+
if not os.path.exists(embeddings_path) or not os.path.exists(metadata_path):
|
| 23 |
+
raise FileNotFoundError(
|
| 24 |
+
f"embeddings.npy or metadata.jsonl not found in {embeddings_dir}. "
|
| 25 |
+
"Run scripts/generate_embeddings.py first."
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
Path(index_dir).mkdir(parents=True, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
embs = np.load(embeddings_path).astype("float32")
|
| 31 |
+
if embs.ndim != 2:
|
| 32 |
+
raise ValueError("embeddings.npy must be a 2D array [N, D]")
|
| 33 |
+
|
| 34 |
+
num_vectors, dim = embs.shape
|
| 35 |
+
print(f"Loaded embeddings: {num_vectors} vectors of dimension {dim}")
|
| 36 |
+
|
| 37 |
+
faiss.normalize_L2(embs)
|
| 38 |
+
|
| 39 |
+
index = faiss.IndexFlatIP(dim)
|
| 40 |
+
index.add(embs)
|
| 41 |
+
|
| 42 |
+
index_file = os.path.join(index_dir, "faiss.index")
|
| 43 |
+
faiss.write_index(index, index_file)
|
| 44 |
+
print("Index written to", index_file)
|
| 45 |
+
|
| 46 |
+
target_metadata_path = os.path.join(index_dir, "metadata.jsonl")
|
| 47 |
+
shutil.copyfile(metadata_path, target_metadata_path)
|
| 48 |
+
print("Metadata copied to", target_metadata_path)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
build_faiss_index()
|
scripts/embd_index.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from utils import base_utils as bu
|
| 2 |
+
from utils import md_to_faiss
|
| 3 |
+
from utils import retrieval_utils as ru
|
| 4 |
+
|
| 5 |
+
def main():
|
| 6 |
+
config = bu.load_config("configs/config.json")
|
| 7 |
+
model_name = config["embeddings"]["model_name"]
|
| 8 |
+
input_path = config["paths"]["input_path"]
|
| 9 |
+
index_dir = config["paths"]["index_dir"]
|
| 10 |
+
output_dir = config["embeddings"]["output_dir"]
|
| 11 |
+
splitter = config["splitter"]["type"]
|
| 12 |
+
chunk_size = config["splitter"]["chunk_size"]
|
| 13 |
+
chunk_overlap = config["splitter"]["overlap"]
|
| 14 |
+
max_docs = config["embeddings"]["max_files_for_debug"]
|
| 15 |
+
shuffle = bool(config["embeddings"].get("shuffle_sample", False))
|
| 16 |
+
random_seed = config["embeddings"].get("random_seed")
|
| 17 |
+
require_frontmatter = bool(config["embeddings"].get("require_frontmatter", False))
|
| 18 |
+
priority_keywords = config["embeddings"].get("priority_keywords") or []
|
| 19 |
+
priority_max = config["embeddings"].get("priority_max")
|
| 20 |
+
retrieval_model = ru.load_model(model_name)
|
| 21 |
+
|
| 22 |
+
md_to_faiss.build_faiss_from_md(
|
| 23 |
+
input_path = input_path,
|
| 24 |
+
index_dir = index_dir,
|
| 25 |
+
model_name = model_name,
|
| 26 |
+
splitter = splitter,
|
| 27 |
+
chunk_size = chunk_size,
|
| 28 |
+
chunk_overlap = chunk_overlap,
|
| 29 |
+
retrieval_model = retrieval_model,
|
| 30 |
+
max_documents = max_docs,
|
| 31 |
+
shuffle = shuffle,
|
| 32 |
+
random_seed = random_seed,
|
| 33 |
+
require_frontmatter = require_frontmatter,
|
| 34 |
+
priority_keywords = priority_keywords,
|
| 35 |
+
priority_max = priority_max,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
if __name__ == "__main__":
|
| 39 |
+
main()
|
scripts/generate_embeddings.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 4 |
+
if ROOT not in sys.path:
|
| 5 |
+
sys.path.insert(0, ROOT)
|
| 6 |
+
from utils import base_utils as bu
|
| 7 |
+
from utils import retrieval_utils as ru
|
| 8 |
+
|
| 9 |
+
def main():
|
| 10 |
+
config = bu.load_config("configs/config.json")
|
| 11 |
+
|
| 12 |
+
os.makedirs(config["embeddings"]["output_dir"], exist_ok=True)
|
| 13 |
+
|
| 14 |
+
model_name = config["embeddings"]["model_name"]
|
| 15 |
+
input_path = config["paths"]["input_path"]
|
| 16 |
+
output_dir = config["embeddings"]["output_dir"]
|
| 17 |
+
splitter = config["splitter"]["type"]
|
| 18 |
+
chunk_size = config["splitter"]["chunk_size"]
|
| 19 |
+
chunk_overlap = config["splitter"]["overlap"]
|
| 20 |
+
retrieval_model = ru.load_model(model_name)
|
| 21 |
+
|
| 22 |
+
numpy_output_dir = config.get("paths", {}).get("embeddings_dir", "data/embeddings")
|
| 23 |
+
max_files = config.get("embeddings", {}).get("max_files_for_debug") or None
|
| 24 |
+
|
| 25 |
+
ru.generate_embeddings(
|
| 26 |
+
chunk_size=chunk_size,
|
| 27 |
+
chunk_overlap=chunk_overlap,
|
| 28 |
+
model_name=model_name,
|
| 29 |
+
input_path=input_path,
|
| 30 |
+
output_folder=output_dir,
|
| 31 |
+
splitter=splitter,
|
| 32 |
+
retrieval_model=retrieval_model,
|
| 33 |
+
export_numpy=True,
|
| 34 |
+
numpy_output_dir=numpy_output_dir,
|
| 35 |
+
max_files=max_files,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
if __name__ == "__main__":
|
| 39 |
+
main()
|
scripts/index_to_elasticsearch.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
from typing import Dict, Any
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
from elasticsearch import Elasticsearch, helpers
|
| 7 |
+
|
| 8 |
+
from utils import base_utils as bu
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def load_embeddings_and_metadata(config: Dict[str, Any]):
|
| 12 |
+
paths = config.get("paths", {})
|
| 13 |
+
emb_dir = paths.get("embeddings_dir", "data/embeddings")
|
| 14 |
+
|
| 15 |
+
emb_path = os.path.join(emb_dir, "embeddings.npy")
|
| 16 |
+
meta_path = os.path.join(emb_dir, "metadata.jsonl")
|
| 17 |
+
|
| 18 |
+
if not os.path.exists(emb_path) or not os.path.exists(meta_path):
|
| 19 |
+
raise FileNotFoundError(
|
| 20 |
+
f"embeddings.npy or metadata.jsonl not found in {emb_dir}. "
|
| 21 |
+
"Run scripts.generate_embeddings first."
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
embeddings = np.load(emb_path)
|
| 25 |
+
|
| 26 |
+
metadata = []
|
| 27 |
+
with open(meta_path, "r", encoding="utf-8") as f:
|
| 28 |
+
for line in f:
|
| 29 |
+
if line.strip():
|
| 30 |
+
metadata.append(json.loads(line))
|
| 31 |
+
|
| 32 |
+
if embeddings.shape[0] != len(metadata):
|
| 33 |
+
raise ValueError(
|
| 34 |
+
f"Embeddings count ({embeddings.shape[0]}) and metadata lines ({len(metadata)}) do not match."
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
return embeddings, metadata
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def ensure_index(client: Elasticsearch, index_name: str, dims: int, vector_field: str = "embedding"):
|
| 41 |
+
if client.indices.exists(index=index_name):
|
| 42 |
+
return
|
| 43 |
+
|
| 44 |
+
body = {
|
| 45 |
+
"mappings": {
|
| 46 |
+
"properties": {
|
| 47 |
+
"idx": {"type": "integer"},
|
| 48 |
+
"document_id": {"type": "keyword"},
|
| 49 |
+
"document_title": {"type": "text", "fields": {"raw": {"type": "keyword"}}},
|
| 50 |
+
"fragment_id": {"type": "integer"},
|
| 51 |
+
"content": {"type": "text"},
|
| 52 |
+
vector_field: {"type": "dense_vector", "dims": dims},
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
client.indices.create(index=index_name, body=body)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def bulk_index_to_es(config: Dict[str, Any]):
|
| 60 |
+
idx_cfg = config.get("index", {})
|
| 61 |
+
host = idx_cfg.get("host", "http://localhost:9200")
|
| 62 |
+
index_name = idx_cfg.get("index_name", "chatbot-norm")
|
| 63 |
+
vector_field = idx_cfg.get("vector_field", "embedding")
|
| 64 |
+
|
| 65 |
+
embeddings, metadata = load_embeddings_and_metadata(config)
|
| 66 |
+
api_key = idx_cfg.get("api_key") or os.getenv("ELASTIC_API_KEY")
|
| 67 |
+
username = idx_cfg.get("username")
|
| 68 |
+
password = idx_cfg.get("password")
|
| 69 |
+
|
| 70 |
+
if api_key:
|
| 71 |
+
client = Elasticsearch(host, api_key=api_key)
|
| 72 |
+
elif username and password:
|
| 73 |
+
client = Elasticsearch(host, basic_auth=(username, password))
|
| 74 |
+
else:
|
| 75 |
+
client = Elasticsearch(host)
|
| 76 |
+
|
| 77 |
+
dims = embeddings.shape[1]
|
| 78 |
+
ensure_index(client, index_name, dims=dims, vector_field=vector_field)
|
| 79 |
+
|
| 80 |
+
def gen_actions():
|
| 81 |
+
for emb_vec, meta in zip(embeddings, metadata):
|
| 82 |
+
doc = {
|
| 83 |
+
"idx": meta.get("idx"),
|
| 84 |
+
"document_id": meta.get("document_id"),
|
| 85 |
+
"document_title": meta.get("document_title"),
|
| 86 |
+
"fragment_id": meta.get("fragment_id"),
|
| 87 |
+
"content": meta.get("content"),
|
| 88 |
+
vector_field: emb_vec.astype(float).tolist(),
|
| 89 |
+
}
|
| 90 |
+
yield {
|
| 91 |
+
"_index": index_name,
|
| 92 |
+
"_source": doc,
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
helpers.bulk(client, gen_actions())
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def main():
|
| 99 |
+
config = bu.load_config("configs/config.json")
|
| 100 |
+
bulk_index_to_es(config)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
main()
|
scripts/normalize_md_titles.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import glob
|
| 3 |
+
|
| 4 |
+
DOCS_DIR = "Docs"
|
| 5 |
+
MAX_SCAN_LINES = 30
|
| 6 |
+
|
| 7 |
+
def guess_title_from_filename(filename: str) -> str:
|
| 8 |
+
"""Crea un título legible a partir del nombre de archivo."""
|
| 9 |
+
base = os.path.splitext(os.path.basename(filename))[0]
|
| 10 |
+
|
| 11 |
+
title = base.replace("_", " ").replace("-", " ")
|
| 12 |
+
|
| 13 |
+
if title.isupper():
|
| 14 |
+
title = title.title()
|
| 15 |
+
return title.strip()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def normalize_text_for_match(text: str) -> str:
|
| 19 |
+
"""Normaliza texto para comparación aproximada (minúsculas, sin símbolos comunes)."""
|
| 20 |
+
cleaned = text.replace("_", " ").replace("-", " ")
|
| 21 |
+
cleaned = cleaned.replace("(", " ").replace(")", " ")
|
| 22 |
+
cleaned = cleaned.replace(",", " ").replace(".", " ")
|
| 23 |
+
cleaned = " ".join(cleaned.split())
|
| 24 |
+
return cleaned.lower()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def looks_like_title(line: str) -> bool:
|
| 28 |
+
"""Heurística sencilla para detectar líneas "tipo título" (no muy largas, poco ruido numérico)."""
|
| 29 |
+
txt = line.strip()
|
| 30 |
+
if not txt:
|
| 31 |
+
return False
|
| 32 |
+
# Evitar URLs claras
|
| 33 |
+
if "http://" in txt or "https://" in txt:
|
| 34 |
+
return False
|
| 35 |
+
|
| 36 |
+
if len(txt) > 200:
|
| 37 |
+
return False
|
| 38 |
+
|
| 39 |
+
digits = sum(c.isdigit() for c in txt)
|
| 40 |
+
if digits > len(txt) * 0.4:
|
| 41 |
+
return False
|
| 42 |
+
return True
|
| 43 |
+
|
| 44 |
+
def normalize_md_file(path: str) -> None:
|
| 45 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 46 |
+
lines = f.readlines()
|
| 47 |
+
|
| 48 |
+
if not lines:
|
| 49 |
+
return
|
| 50 |
+
|
| 51 |
+
filename_title = guess_title_from_filename(path)
|
| 52 |
+
filename_norm = normalize_text_for_match(filename_title)
|
| 53 |
+
|
| 54 |
+
best_idx = None
|
| 55 |
+
best_title = None
|
| 56 |
+
best_score = 0.0
|
| 57 |
+
|
| 58 |
+
for i, line in enumerate(lines[:MAX_SCAN_LINES]):
|
| 59 |
+
raw_line = line.rstrip("\n")
|
| 60 |
+
stripped = raw_line.strip()
|
| 61 |
+
if not stripped:
|
| 62 |
+
continue
|
| 63 |
+
|
| 64 |
+
is_heading = stripped.startswith("#")
|
| 65 |
+
candidate = stripped.lstrip("#").strip() if is_heading else stripped
|
| 66 |
+
|
| 67 |
+
if not looks_like_title(candidate):
|
| 68 |
+
continue
|
| 69 |
+
|
| 70 |
+
cand_norm = normalize_text_for_match(candidate)
|
| 71 |
+
if not cand_norm:
|
| 72 |
+
continue
|
| 73 |
+
|
| 74 |
+
fname_words = set(filename_norm.split())
|
| 75 |
+
cand_words = set(cand_norm.split())
|
| 76 |
+
if not fname_words:
|
| 77 |
+
overlap = 0.0
|
| 78 |
+
else:
|
| 79 |
+
overlap = len(fname_words & cand_words) / len(fname_words)
|
| 80 |
+
|
| 81 |
+
score = overlap
|
| 82 |
+
|
| 83 |
+
if is_heading:
|
| 84 |
+
score += 0.1
|
| 85 |
+
|
| 86 |
+
if score > best_score:
|
| 87 |
+
best_score = score
|
| 88 |
+
best_idx = i
|
| 89 |
+
best_title = candidate
|
| 90 |
+
|
| 91 |
+
# Umbral: si no encontramos nada razonable, usamos el nombre del archivo
|
| 92 |
+
if best_title is None or best_score < 0.3:
|
| 93 |
+
new_title_text = filename_title
|
| 94 |
+
insert_idx = 0
|
| 95 |
+
for i, line in enumerate(lines):
|
| 96 |
+
if line.strip():
|
| 97 |
+
insert_idx = i
|
| 98 |
+
break
|
| 99 |
+
new_title = f"# {new_title_text}\n"
|
| 100 |
+
lines.insert(insert_idx, new_title + "\n")
|
| 101 |
+
else:
|
| 102 |
+
raw_norm = best_title.replace("_", " ").replace("-", " ")
|
| 103 |
+
if raw_norm.isupper():
|
| 104 |
+
raw_norm = raw_norm.title()
|
| 105 |
+
new_title = f"# {raw_norm}\n"
|
| 106 |
+
lines[best_idx] = new_title
|
| 107 |
+
|
| 108 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 109 |
+
f.writelines(lines)
|
| 110 |
+
|
| 111 |
+
def main():
|
| 112 |
+
pattern = os.path.join(DOCS_DIR, "*.md")
|
| 113 |
+
files = glob.glob(pattern)
|
| 114 |
+
print(f"Encontrados {len(files)} arquivos .md em {DOCS_DIR}")
|
| 115 |
+
|
| 116 |
+
for i, path in enumerate(files, start=1):
|
| 117 |
+
print(f"[{i}/{len(files)}] Normalizando título de: {os.path.basename(path)}")
|
| 118 |
+
try:
|
| 119 |
+
normalize_md_file(path)
|
| 120 |
+
except Exception as e:
|
| 121 |
+
print(f" -> Erro ao processar {path}: {e}")
|
| 122 |
+
|
| 123 |
+
print("Normalização de títulos concluída.")
|
| 124 |
+
|
| 125 |
+
if __name__ == "__main__":
|
| 126 |
+
main()
|
scripts/test_retrieval_cli.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""CLI para probar retrieval del backend (sin frontend).
|
| 3 |
+
|
| 4 |
+
Uso:
|
| 5 |
+
python scripts/test_retrieval_cli.py
|
| 6 |
+
python scripts/test_retrieval_cli.py --question "O que é NORM?"
|
| 7 |
+
python scripts/test_retrieval_cli.py --question "césio-137" --top-k 20
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import sys
|
| 14 |
+
from typing import Any, Dict, List
|
| 15 |
+
|
| 16 |
+
import app.api_server as srv
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _print_header(top_k: int, normalize: bool) -> None:
|
| 20 |
+
print("=" * 90)
|
| 21 |
+
print("Teste de retrieval (modo chatbot)")
|
| 22 |
+
print(f"top_k efetivo: {top_k}")
|
| 23 |
+
print(f"index.type: {srv.CONFIG.get('index', {}).get('type', 'faiss')}")
|
| 24 |
+
print(f"normalize L2: {normalize}")
|
| 25 |
+
print("=" * 90)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _print_chunk(i: int, chunk: Dict[str, Any], max_chars: int) -> None:
|
| 29 |
+
doc_id = chunk.get("document_id", "N/A")
|
| 30 |
+
title = chunk.get("document_title", "N/A")
|
| 31 |
+
topic = chunk.get("topic", "N/A")
|
| 32 |
+
frag_id = chunk.get("fragment_id", chunk.get("idx", "N/A"))
|
| 33 |
+
score = chunk.get("score")
|
| 34 |
+
cit_id = chunk.get("citation_id")
|
| 35 |
+
text = srv._normalizar_texto(chunk.get("content", ""))
|
| 36 |
+
preview = text[:max_chars] + ("..." if len(text) > max_chars else "")
|
| 37 |
+
|
| 38 |
+
print(f"\n[{i}] citation_id={cit_id} | score={score} | doc_id={doc_id} | frag={frag_id}")
|
| 39 |
+
print(f" title: {title}")
|
| 40 |
+
print(f" topic: {topic}")
|
| 41 |
+
print(f" chunk: {preview}")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _run_retrieval(question: str, top_k_override: int | None, max_chars: int) -> None:
|
| 45 |
+
question = (question or "").strip()
|
| 46 |
+
if not question:
|
| 47 |
+
print("Pergunta vazia. Tente novamente.")
|
| 48 |
+
return
|
| 49 |
+
|
| 50 |
+
pregunta_lower = question.lower().strip()
|
| 51 |
+
|
| 52 |
+
help_triggers = [str(t).lower() for t in srv.TRIGGERS.get("help", [])]
|
| 53 |
+
smalltalk_triggers = [str(t).lower() for t in srv.TRIGGERS.get("smalltalk", [])]
|
| 54 |
+
greeting_triggers = [str(t).lower() for t in srv.TRIGGERS.get("greeting", [])]
|
| 55 |
+
|
| 56 |
+
# Replica o comportamento do endpoint: alguns casos não passam pelo retrieval.
|
| 57 |
+
if srv._contains_any_trigger(pregunta_lower, help_triggers):
|
| 58 |
+
print("[SKIP] A pergunta bateu em trigger de help; no endpoint isso retorna sem retrieval.")
|
| 59 |
+
print(f"Resposta esperada: {srv.HELP_SCOPE_TEXT}")
|
| 60 |
+
return
|
| 61 |
+
if srv._contains_any_trigger(pregunta_lower, smalltalk_triggers):
|
| 62 |
+
print("[SKIP] A pergunta bateu em trigger de smalltalk; no endpoint isso retorna sem retrieval.")
|
| 63 |
+
print(f"Resposta esperada: {srv.ABOUT_BOT_TEXT}")
|
| 64 |
+
return
|
| 65 |
+
if srv._contains_any_trigger(pregunta_lower, greeting_triggers):
|
| 66 |
+
print("[SKIP] A pergunta bateu em trigger de greeting; no endpoint isso retorna sem retrieval.")
|
| 67 |
+
print(f"Resposta esperada: {srv.GREETING_TEXT}")
|
| 68 |
+
return
|
| 69 |
+
|
| 70 |
+
top_k = top_k_override or srv.CONFIG.get("retrieve", {}).get("top_k", 4)
|
| 71 |
+
chatbot_min_top_k = int(srv.CONFIG.get("retrieve", {}).get("chatbot_top_k", 16))
|
| 72 |
+
top_k = max(int(top_k), chatbot_min_top_k)
|
| 73 |
+
|
| 74 |
+
index_type = srv.CONFIG.get("index", {}).get("type", "faiss").lower()
|
| 75 |
+
normalize = index_type == "faiss"
|
| 76 |
+
|
| 77 |
+
_print_header(top_k=top_k, normalize=normalize)
|
| 78 |
+
|
| 79 |
+
q_vec = srv.embed_query(srv.EMBED_MODEL, question, normalize=normalize)
|
| 80 |
+
retrieved: List[Dict[str, Any]] = srv.RETRIEVER.retrieve(q_vec, top_k)
|
| 81 |
+
|
| 82 |
+
citation_ids: Dict[str, int] = {}
|
| 83 |
+
next_id = 1
|
| 84 |
+
for m in retrieved:
|
| 85 |
+
doc_id = m.get("document_id")
|
| 86 |
+
if not doc_id:
|
| 87 |
+
continue
|
| 88 |
+
if doc_id not in citation_ids:
|
| 89 |
+
citation_ids[doc_id] = next_id
|
| 90 |
+
next_id += 1
|
| 91 |
+
m["citation_id"] = citation_ids[doc_id]
|
| 92 |
+
|
| 93 |
+
print(f"Pergunta: {question}")
|
| 94 |
+
print(f"Chunks recuperados: {len(retrieved)}")
|
| 95 |
+
|
| 96 |
+
if not retrieved:
|
| 97 |
+
print("Nenhum chunk recuperado.")
|
| 98 |
+
return
|
| 99 |
+
|
| 100 |
+
for i, chunk in enumerate(retrieved, start=1):
|
| 101 |
+
_print_chunk(i=i, chunk=chunk, max_chars=max_chars)
|
| 102 |
+
|
| 103 |
+
print("\nFim.\n")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def main() -> int:
|
| 107 |
+
parser = argparse.ArgumentParser(description="Testa retrieval do backend no modo chatbot")
|
| 108 |
+
parser.add_argument("--question", "-q", type=str, help="Pergunta para execução única")
|
| 109 |
+
parser.add_argument("--top-k", type=int, default=None, help="Override de top_k")
|
| 110 |
+
parser.add_argument(
|
| 111 |
+
"--max-chars",
|
| 112 |
+
type=int,
|
| 113 |
+
default=320,
|
| 114 |
+
help="Máximo de caracteres exibidos por chunk",
|
| 115 |
+
)
|
| 116 |
+
args = parser.parse_args()
|
| 117 |
+
|
| 118 |
+
if args.question:
|
| 119 |
+
_run_retrieval(args.question, args.top_k, args.max_chars)
|
| 120 |
+
return 0
|
| 121 |
+
|
| 122 |
+
print("Modo interativo. Digite sua pergunta (ou 'sair' para encerrar).")
|
| 123 |
+
while True:
|
| 124 |
+
try:
|
| 125 |
+
q = input("\nPergunta> ").strip()
|
| 126 |
+
except (EOFError, KeyboardInterrupt):
|
| 127 |
+
print("\nEncerrado.")
|
| 128 |
+
return 0
|
| 129 |
+
|
| 130 |
+
if q.lower() in {"sair", "exit", "quit"}:
|
| 131 |
+
print("Encerrado.")
|
| 132 |
+
return 0
|
| 133 |
+
|
| 134 |
+
_run_retrieval(q, args.top_k, args.max_chars)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
if __name__ == "__main__":
|
| 138 |
+
sys.exit(main())
|
utils/base_utils.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def load_config(config_path="configs/config.json"):
|
| 6 |
+
with open(config_path, "r", encoding="utf-8") as f:
|
| 7 |
+
config = json.load(f)
|
| 8 |
+
return config
|
| 9 |
+
|
| 10 |
+
def load_prompts(path="configs/prompts.json"):
|
| 11 |
+
"""Carrega o arquivo completo de prompts.
|
| 12 |
+
|
| 13 |
+
Retorna um dicionário com todas as seções definidas
|
| 14 |
+
(em especial "system_prompts" e "meta"), para que
|
| 15 |
+
tanto o LLM quanto as respostas fixas usem a mesma
|
| 16 |
+
fonte de configuração.
|
| 17 |
+
"""
|
| 18 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 19 |
+
return json.load(f)
|
| 20 |
+
|
| 21 |
+
def load_md(file_path):
|
| 22 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 23 |
+
text = f.read()
|
| 24 |
+
return text
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def extract_frontmatter(text: str) -> dict:
|
| 28 |
+
"""Extrae un frontmatter YAML básico delimitado por '---' al inicio.
|
| 29 |
+
|
| 30 |
+
Soporta claves simples y listas en formato:
|
| 31 |
+
authors:\n - "A"\n - "B"
|
| 32 |
+
"""
|
| 33 |
+
if not text:
|
| 34 |
+
return {}
|
| 35 |
+
|
| 36 |
+
lines = text.splitlines()
|
| 37 |
+
if not lines or lines[0].strip() != "---":
|
| 38 |
+
return {}
|
| 39 |
+
|
| 40 |
+
frontmatter_lines = []
|
| 41 |
+
for line in lines[1:]:
|
| 42 |
+
if line.strip() == "---":
|
| 43 |
+
break
|
| 44 |
+
frontmatter_lines.append(line)
|
| 45 |
+
|
| 46 |
+
data: dict = {}
|
| 47 |
+
current_key = None
|
| 48 |
+
for raw in frontmatter_lines:
|
| 49 |
+
line = raw.rstrip()
|
| 50 |
+
if not line.strip():
|
| 51 |
+
continue
|
| 52 |
+
|
| 53 |
+
if line.lstrip().startswith("- ") and current_key:
|
| 54 |
+
value = line.lstrip()[2:].strip().strip('"').strip("'")
|
| 55 |
+
data.setdefault(current_key, [])
|
| 56 |
+
if isinstance(data[current_key], list):
|
| 57 |
+
data[current_key].append(value)
|
| 58 |
+
continue
|
| 59 |
+
|
| 60 |
+
if ":" in line:
|
| 61 |
+
key, value = line.split(":", 1)
|
| 62 |
+
key = key.strip()
|
| 63 |
+
value = value.strip().strip('"').strip("'")
|
| 64 |
+
if value == "":
|
| 65 |
+
current_key = key
|
| 66 |
+
data[key] = []
|
| 67 |
+
else:
|
| 68 |
+
current_key = key
|
| 69 |
+
data[key] = value
|
| 70 |
+
|
| 71 |
+
return data
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def extract_title_from_md(text: str, default: str) -> str:
|
| 75 |
+
|
| 76 |
+
if not text:
|
| 77 |
+
return default
|
| 78 |
+
|
| 79 |
+
def _es_generico_ou_numero(line: str) -> bool:
|
| 80 |
+
"""Detecta líneas poco informativas: solo número/año o rótulos genéricos."""
|
| 81 |
+
val = line.strip()
|
| 82 |
+
if not val:
|
| 83 |
+
return True
|
| 84 |
+
|
| 85 |
+
# Solo dígitos ("2007", "69", etc.)
|
| 86 |
+
if val.isdigit():
|
| 87 |
+
return True
|
| 88 |
+
|
| 89 |
+
# Año de 4 dígitos aislado
|
| 90 |
+
if len(val) == 4 and val.isdigit():
|
| 91 |
+
return True
|
| 92 |
+
|
| 93 |
+
lower = val.lower()
|
| 94 |
+
genericos = {
|
| 95 |
+
"article",
|
| 96 |
+
"artigo",
|
| 97 |
+
"artículo",
|
| 98 |
+
"issue",
|
| 99 |
+
"number",
|
| 100 |
+
"número",
|
| 101 |
+
}
|
| 102 |
+
if lower in genericos:
|
| 103 |
+
return True
|
| 104 |
+
|
| 105 |
+
return False
|
| 106 |
+
|
| 107 |
+
lines = [l.rstrip("\n") for l in text.splitlines()]
|
| 108 |
+
|
| 109 |
+
# 1) Buscar primer heading "#" que no sea genérico/numérico
|
| 110 |
+
for raw_line in lines:
|
| 111 |
+
line = raw_line.strip()
|
| 112 |
+
if not line:
|
| 113 |
+
continue
|
| 114 |
+
if line.startswith("#"):
|
| 115 |
+
candidate = line.lstrip("#").strip()
|
| 116 |
+
if candidate and not _es_generico_ou_numero(candidate):
|
| 117 |
+
return candidate
|
| 118 |
+
|
| 119 |
+
# 2) Si no hay heading válido, buscar primera línea de texto informativa
|
| 120 |
+
for raw_line in lines:
|
| 121 |
+
line = raw_line.strip()
|
| 122 |
+
if not line:
|
| 123 |
+
continue
|
| 124 |
+
if not _es_generico_ou_numero(line):
|
| 125 |
+
return line
|
| 126 |
+
|
| 127 |
+
# 3) Fallback
|
| 128 |
+
return default
|
utils/conversation_word_export.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from io import BytesIO
|
| 3 |
+
from typing import Any, Dict, List, Tuple
|
| 4 |
+
|
| 5 |
+
from docx import Document
|
| 6 |
+
from docx.oxml import OxmlElement
|
| 7 |
+
from docx.oxml.ns import qn
|
| 8 |
+
from docx.shared import Pt, RGBColor
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# ---------------------------------------------------------------------------
|
| 12 |
+
# Markdown table helpers
|
| 13 |
+
# ---------------------------------------------------------------------------
|
| 14 |
+
|
| 15 |
+
_TABLE_ROW_RE = re.compile(r"^\|(.+)\|$")
|
| 16 |
+
_SEPARATOR_RE = re.compile(r"^\|[-:| ]+\|$")
|
| 17 |
+
_INLINE_MD_RE = re.compile(r"\*{1,2}([^*]+)\*{1,2}|`([^`]+)`")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _strip_inline_md(text: str) -> str:
|
| 21 |
+
"""Remove common inline markdown markers (bold, italic, code) from text."""
|
| 22 |
+
return _INLINE_MD_RE.sub(lambda m: m.group(1) or m.group(2), text)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _is_table_separator(line: str) -> bool:
|
| 26 |
+
return bool(_SEPARATOR_RE.match(line.strip()))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _parse_table_rows(lines: List[str]) -> List[List[str]]:
|
| 30 |
+
"""Convert markdown table lines into a list of rows (list of cell strings)."""
|
| 31 |
+
rows: List[List[str]] = []
|
| 32 |
+
for line in lines:
|
| 33 |
+
if _is_table_separator(line):
|
| 34 |
+
continue
|
| 35 |
+
m = _TABLE_ROW_RE.match(line.strip())
|
| 36 |
+
if m:
|
| 37 |
+
cells = [_strip_inline_md(c.strip()) for c in m.group(1).split("|")]
|
| 38 |
+
rows.append(cells)
|
| 39 |
+
return rows
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _shade_cell(cell, hex_color: str) -> None:
|
| 43 |
+
"""Apply a background fill colour to a table cell."""
|
| 44 |
+
tc = cell._tc
|
| 45 |
+
tcPr = tc.get_or_add_tcPr()
|
| 46 |
+
shd = OxmlElement("w:shd")
|
| 47 |
+
shd.set(qn("w:val"), "clear")
|
| 48 |
+
shd.set(qn("w:color"), "auto")
|
| 49 |
+
shd.set(qn("w:fill"), hex_color)
|
| 50 |
+
tcPr.append(shd)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _add_markdown_table(doc: Document, lines: List[str]) -> None:
|
| 54 |
+
"""Render a markdown table as a formatted Word table."""
|
| 55 |
+
rows = _parse_table_rows(lines)
|
| 56 |
+
if not rows:
|
| 57 |
+
return
|
| 58 |
+
|
| 59 |
+
max_cols = max(len(r) for r in rows)
|
| 60 |
+
table = doc.add_table(rows=len(rows), cols=max_cols)
|
| 61 |
+
table.style = "Table Grid"
|
| 62 |
+
|
| 63 |
+
for r_idx, row in enumerate(rows):
|
| 64 |
+
tr = table.rows[r_idx]
|
| 65 |
+
for c_idx in range(max_cols):
|
| 66 |
+
cell_text = row[c_idx] if c_idx < len(row) else ""
|
| 67 |
+
cell = tr.cells[c_idx]
|
| 68 |
+
para = cell.paragraphs[0]
|
| 69 |
+
run = para.add_run(cell_text)
|
| 70 |
+
if r_idx == 0:
|
| 71 |
+
run.bold = True
|
| 72 |
+
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
|
| 73 |
+
_shade_cell(cell, "2E74B5") # blue header
|
| 74 |
+
|
| 75 |
+
doc.add_paragraph() # spacing after table
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
# Content block splitter
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
|
| 82 |
+
def _split_into_blocks(content: str) -> List[Tuple[str, Any]]:
|
| 83 |
+
"""
|
| 84 |
+
Split markdown content into alternating ("text", str) and ("table", list[str])
|
| 85 |
+
blocks so each can be rendered appropriately.
|
| 86 |
+
"""
|
| 87 |
+
blocks: List[Tuple[str, Any]] = []
|
| 88 |
+
text_lines: List[str] = []
|
| 89 |
+
table_lines: List[str] = []
|
| 90 |
+
in_table = False
|
| 91 |
+
|
| 92 |
+
for line in content.split("\n"):
|
| 93 |
+
stripped = line.strip()
|
| 94 |
+
is_table_line = (
|
| 95 |
+
stripped.startswith("|")
|
| 96 |
+
and stripped.endswith("|")
|
| 97 |
+
and len(stripped) > 2
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
if is_table_line:
|
| 101 |
+
if not in_table:
|
| 102 |
+
if text_lines:
|
| 103 |
+
blocks.append(("text", "\n".join(text_lines)))
|
| 104 |
+
text_lines = []
|
| 105 |
+
in_table = True
|
| 106 |
+
table_lines.append(line)
|
| 107 |
+
else:
|
| 108 |
+
if in_table:
|
| 109 |
+
blocks.append(("table", list(table_lines)))
|
| 110 |
+
table_lines = []
|
| 111 |
+
in_table = False
|
| 112 |
+
text_lines.append(line)
|
| 113 |
+
|
| 114 |
+
if in_table and table_lines:
|
| 115 |
+
blocks.append(("table", table_lines))
|
| 116 |
+
elif text_lines:
|
| 117 |
+
blocks.append(("text", "\n".join(text_lines)))
|
| 118 |
+
|
| 119 |
+
return blocks
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _add_content(doc: Document, content: str) -> None:
|
| 123 |
+
"""Add message content to *doc*, converting markdown tables to Word tables."""
|
| 124 |
+
if not content:
|
| 125 |
+
return
|
| 126 |
+
for block_type, data in _split_into_blocks(content):
|
| 127 |
+
if block_type == "table":
|
| 128 |
+
_add_markdown_table(doc, data)
|
| 129 |
+
else:
|
| 130 |
+
text = data.strip()
|
| 131 |
+
if text:
|
| 132 |
+
doc.add_paragraph(text)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ---------------------------------------------------------------------------
|
| 136 |
+
# Public API
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
|
| 139 |
+
def build_conversation_docx(messages: List[Dict[str, Any]]) -> bytes:
|
| 140 |
+
"""Build a .docx file from chat messages and return raw bytes."""
|
| 141 |
+
doc = Document()
|
| 142 |
+
doc.add_heading("Conversa Chatbot NORM ⚛", level=1)
|
| 143 |
+
|
| 144 |
+
for msg in messages:
|
| 145 |
+
role = str(msg.get("role") or "")
|
| 146 |
+
content = str(msg.get("content") or "").strip()
|
| 147 |
+
|
| 148 |
+
if not content:
|
| 149 |
+
continue
|
| 150 |
+
|
| 151 |
+
doc.add_heading(role, level=2)
|
| 152 |
+
_add_content(doc, content)
|
| 153 |
+
|
| 154 |
+
references = str(msg.get("references") or "").strip()
|
| 155 |
+
if references:
|
| 156 |
+
cleaned_refs = references.replace("<br>", "\n")
|
| 157 |
+
doc.add_paragraph("Referencias:")
|
| 158 |
+
doc.add_paragraph(cleaned_refs)
|
| 159 |
+
|
| 160 |
+
buffer = BytesIO()
|
| 161 |
+
doc.save(buffer)
|
| 162 |
+
buffer.seek(0)
|
| 163 |
+
return buffer.getvalue()
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def build_single_response_docx(message: Dict[str, Any]) -> bytes:
|
| 167 |
+
"""Build a .docx file for a single assistant response and return raw bytes."""
|
| 168 |
+
doc = Document()
|
| 169 |
+
doc.add_heading("Ultima resposta do chatbot ⚛", level=1)
|
| 170 |
+
|
| 171 |
+
content = str(message.get("content") or "").strip()
|
| 172 |
+
_add_content(doc, content)
|
| 173 |
+
|
| 174 |
+
references = str(message.get("references") or "").strip()
|
| 175 |
+
if references:
|
| 176 |
+
cleaned_refs = references.replace("<br>", "\n")
|
| 177 |
+
doc.add_paragraph("Referencias:")
|
| 178 |
+
doc.add_paragraph(cleaned_refs)
|
| 179 |
+
|
| 180 |
+
buffer = BytesIO()
|
| 181 |
+
doc.save(buffer)
|
| 182 |
+
buffer.seek(0)
|
| 183 |
+
return buffer.getvalue()
|
utils/md_to_faiss.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import glob
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
import faiss
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from utils import base_utils as bu
|
| 8 |
+
from utils import retrieval_utils as ru
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
TOPIC_KEYWORDS = {
|
| 12 |
+
"Marine and coastal ecosystems": [
|
| 13 |
+
"coastal",
|
| 14 |
+
"coast",
|
| 15 |
+
"estuary",
|
| 16 |
+
"bay",
|
| 17 |
+
"gulf",
|
| 18 |
+
"lagoon",
|
| 19 |
+
"marine ecosystem",
|
| 20 |
+
"biodiversity",
|
| 21 |
+
"seagrass",
|
| 22 |
+
"coral",
|
| 23 |
+
"phytoplankton",
|
| 24 |
+
"zooplankton",
|
| 25 |
+
"sediment",
|
| 26 |
+
"benthic",
|
| 27 |
+
"fish",
|
| 28 |
+
"shellfish",
|
| 29 |
+
"mussel",
|
| 30 |
+
],
|
| 31 |
+
"Marine and environmental studies": [
|
| 32 |
+
"marine",
|
| 33 |
+
"ocean",
|
| 34 |
+
"sea",
|
| 35 |
+
"environment",
|
| 36 |
+
"environmental",
|
| 37 |
+
"ecology",
|
| 38 |
+
"pollution",
|
| 39 |
+
"contamination",
|
| 40 |
+
"monitoring",
|
| 41 |
+
"impact",
|
| 42 |
+
"ecosystem",
|
| 43 |
+
"water quality",
|
| 44 |
+
"biota",
|
| 45 |
+
],
|
| 46 |
+
"Geochemistry and Elemental Analysis": [
|
| 47 |
+
"geochemistry",
|
| 48 |
+
"geochemical",
|
| 49 |
+
"elemental",
|
| 50 |
+
"trace element",
|
| 51 |
+
"isotopic",
|
| 52 |
+
"isotope",
|
| 53 |
+
"mineral",
|
| 54 |
+
"petrology",
|
| 55 |
+
"sediment chemistry",
|
| 56 |
+
"composition",
|
| 57 |
+
"xrf",
|
| 58 |
+
"icp",
|
| 59 |
+
"spectrometry",
|
| 60 |
+
],
|
| 61 |
+
"Radioactivity and Radon Measurements": [
|
| 62 |
+
"radioactivity",
|
| 63 |
+
"radon",
|
| 64 |
+
"radiation",
|
| 65 |
+
"dosimetry",
|
| 66 |
+
"dose",
|
| 67 |
+
"activity concentration",
|
| 68 |
+
"fukushima",
|
| 69 |
+
"cesium",
|
| 70 |
+
"caesium",
|
| 71 |
+
"cs-137",
|
| 72 |
+
"137cs",
|
| 73 |
+
"131i",
|
| 74 |
+
"iodine-131",
|
| 75 |
+
"uranium",
|
| 76 |
+
"thorium",
|
| 77 |
+
"226ra",
|
| 78 |
+
"222rn",
|
| 79 |
+
"220rn",
|
| 80 |
+
"210pb",
|
| 81 |
+
"210po",
|
| 82 |
+
],
|
| 83 |
+
"Hydrocarbon exploration and reservoir analysis": [
|
| 84 |
+
"hydrocarbon",
|
| 85 |
+
"reservoir",
|
| 86 |
+
"petroleum",
|
| 87 |
+
"oil",
|
| 88 |
+
"gas",
|
| 89 |
+
"shale",
|
| 90 |
+
"porosity",
|
| 91 |
+
"permeability",
|
| 92 |
+
"basin",
|
| 93 |
+
"seismic",
|
| 94 |
+
"well log",
|
| 95 |
+
"drilling",
|
| 96 |
+
"production",
|
| 97 |
+
"exploration",
|
| 98 |
+
"source rock",
|
| 99 |
+
],
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _normalize_frontmatter_topic(topic_value):
|
| 104 |
+
if isinstance(topic_value, str):
|
| 105 |
+
value = topic_value.strip()
|
| 106 |
+
return value or None
|
| 107 |
+
|
| 108 |
+
if isinstance(topic_value, list):
|
| 109 |
+
for item in topic_value:
|
| 110 |
+
if isinstance(item, str) and item.strip():
|
| 111 |
+
return item.strip()
|
| 112 |
+
return None
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _infer_topic_from_text(text: str) -> str | None:
|
| 116 |
+
if not text:
|
| 117 |
+
return None
|
| 118 |
+
|
| 119 |
+
lowered = text.lower()
|
| 120 |
+
scores = {}
|
| 121 |
+
|
| 122 |
+
for topic, keywords in TOPIC_KEYWORDS.items():
|
| 123 |
+
score = 0
|
| 124 |
+
for kw in keywords:
|
| 125 |
+
kw = kw.lower().strip()
|
| 126 |
+
if not kw:
|
| 127 |
+
continue
|
| 128 |
+
|
| 129 |
+
# For single words use word boundaries; for phrases count raw matches.
|
| 130 |
+
if " " in kw:
|
| 131 |
+
score += lowered.count(kw)
|
| 132 |
+
else:
|
| 133 |
+
score += len(re.findall(rf"\\b{re.escape(kw)}\\b", lowered))
|
| 134 |
+
|
| 135 |
+
scores[topic] = score
|
| 136 |
+
|
| 137 |
+
best_topic, best_score = max(scores.items(), key=lambda item: item[1])
|
| 138 |
+
return best_topic if best_score > 0 else None
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def build_faiss_from_md(
|
| 142 |
+
input_path: str,
|
| 143 |
+
index_dir: str,
|
| 144 |
+
model_name: str,
|
| 145 |
+
splitter: str,
|
| 146 |
+
chunk_size: int,
|
| 147 |
+
chunk_overlap: int,
|
| 148 |
+
retrieval_model,
|
| 149 |
+
max_documents: int | None = None,
|
| 150 |
+
shuffle: bool = False,
|
| 151 |
+
random_seed: int | None = None,
|
| 152 |
+
require_frontmatter: bool = False,
|
| 153 |
+
priority_keywords: list[str] | None = None,
|
| 154 |
+
priority_scan_chars: int = 4000,
|
| 155 |
+
priority_max: int | None = None,
|
| 156 |
+
):
|
| 157 |
+
"""
|
| 158 |
+
Build or extend a FAISS index directly from markdown documents.
|
| 159 |
+
"""
|
| 160 |
+
|
| 161 |
+
index_dir = os.path.join(index_dir, model_name)
|
| 162 |
+
Path(index_dir).mkdir(parents=True, exist_ok=True)
|
| 163 |
+
index_path = os.path.join(index_dir, "faiss.index")
|
| 164 |
+
metadata_path = os.path.join(index_dir, "metadata.jsonl")
|
| 165 |
+
index_info_path = os.path.join(index_dir, "index_info.json")
|
| 166 |
+
text_splitter = ru.get_text_splitter(splitter, chunk_size, chunk_overlap)
|
| 167 |
+
md_files = sorted(glob.glob(input_path))
|
| 168 |
+
|
| 169 |
+
if not md_files:
|
| 170 |
+
raise RuntimeError(f"No markdown files found at {input_path}")
|
| 171 |
+
|
| 172 |
+
if os.path.exists(index_path):
|
| 173 |
+
print("Existing FAISS index found. Loading...")
|
| 174 |
+
index = faiss.read_index(index_path)
|
| 175 |
+
dim = index.d
|
| 176 |
+
global_idx = index.ntotal
|
| 177 |
+
else:
|
| 178 |
+
print("No existing FAISS index found. Creating new one...")
|
| 179 |
+
index = None
|
| 180 |
+
dim = None
|
| 181 |
+
global_idx = 0
|
| 182 |
+
|
| 183 |
+
indexed_docs = set()
|
| 184 |
+
if os.path.exists(metadata_path):
|
| 185 |
+
with open(metadata_path, "r", encoding="utf-8") as f:
|
| 186 |
+
for line in f:
|
| 187 |
+
if line.strip():
|
| 188 |
+
try:
|
| 189 |
+
m = json.loads(line)
|
| 190 |
+
doc_id = m.get("document_id")
|
| 191 |
+
if doc_id:
|
| 192 |
+
indexed_docs.add(doc_id)
|
| 193 |
+
except json.JSONDecodeError:
|
| 194 |
+
pass
|
| 195 |
+
|
| 196 |
+
print(f"Already indexed documents: {len(indexed_docs)}")
|
| 197 |
+
|
| 198 |
+
md_files = [
|
| 199 |
+
f for f in md_files
|
| 200 |
+
if os.path.splitext(os.path.basename(f))[0] not in indexed_docs
|
| 201 |
+
]
|
| 202 |
+
|
| 203 |
+
# Filtra archivos sin frontmatter YAML válido (si se pidió).
|
| 204 |
+
if require_frontmatter:
|
| 205 |
+
filtered: list[str] = []
|
| 206 |
+
for f in md_files:
|
| 207 |
+
try:
|
| 208 |
+
with open(f, "r", encoding="utf-8") as fh:
|
| 209 |
+
head = fh.read(4096)
|
| 210 |
+
except OSError:
|
| 211 |
+
continue
|
| 212 |
+
if head.lstrip().startswith("---") and "title:" in head:
|
| 213 |
+
filtered.append(f)
|
| 214 |
+
print(f"Files with frontmatter: {len(filtered)} / {len(md_files)}")
|
| 215 |
+
md_files = filtered
|
| 216 |
+
|
| 217 |
+
# Separa archivos prioritarios (matchean alguna keyword) del resto.
|
| 218 |
+
priority_files: list[str] = []
|
| 219 |
+
other_files: list[str] = md_files
|
| 220 |
+
if priority_keywords:
|
| 221 |
+
patterns = [re.compile(kw, flags=re.IGNORECASE) for kw in priority_keywords]
|
| 222 |
+
priority_files = []
|
| 223 |
+
other_files = []
|
| 224 |
+
for f in md_files:
|
| 225 |
+
name = os.path.basename(f)
|
| 226 |
+
matched = any(p.search(name) for p in patterns)
|
| 227 |
+
if not matched:
|
| 228 |
+
try:
|
| 229 |
+
with open(f, "r", encoding="utf-8") as fh:
|
| 230 |
+
sample = fh.read(priority_scan_chars)
|
| 231 |
+
matched = any(p.search(sample) for p in patterns)
|
| 232 |
+
except OSError:
|
| 233 |
+
matched = False
|
| 234 |
+
(priority_files if matched else other_files).append(f)
|
| 235 |
+
print(
|
| 236 |
+
f"Priority matches ({priority_keywords!r}): {len(priority_files)}; "
|
| 237 |
+
f"others: {len(other_files)}"
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
if shuffle:
|
| 241 |
+
import random as _random
|
| 242 |
+
rng = _random.Random(random_seed)
|
| 243 |
+
rng.shuffle(priority_files)
|
| 244 |
+
rng.shuffle(other_files)
|
| 245 |
+
print(
|
| 246 |
+
f"Shuffled candidates (seed={random_seed!r}) "
|
| 247 |
+
f"[priority={len(priority_files)}, other={len(other_files)}]"
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
# Aplica cupo máximo a prioritarios para dejar espacio a variedad.
|
| 251 |
+
if priority_max is not None and len(priority_files) > priority_max:
|
| 252 |
+
leftovers = priority_files[priority_max:]
|
| 253 |
+
priority_files = priority_files[:priority_max]
|
| 254 |
+
other_files = other_files + leftovers
|
| 255 |
+
if shuffle:
|
| 256 |
+
import random as _random2
|
| 257 |
+
_random2.Random(random_seed).shuffle(other_files)
|
| 258 |
+
print(
|
| 259 |
+
f"Priority capped at {priority_max}; "
|
| 260 |
+
f"remaining priority moved to pool (others={len(other_files)})"
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
md_files = priority_files + other_files
|
| 264 |
+
|
| 265 |
+
if max_documents is not None:
|
| 266 |
+
md_files = md_files[:max_documents]
|
| 267 |
+
|
| 268 |
+
total_docs = len(md_files)
|
| 269 |
+
if total_docs == 0:
|
| 270 |
+
print("No new documents to index. FAISS is up to date.")
|
| 271 |
+
return
|
| 272 |
+
|
| 273 |
+
print(f"Indexing {total_docs} new documents")
|
| 274 |
+
|
| 275 |
+
with open(metadata_path, "a", encoding="utf-8") as meta_f:
|
| 276 |
+
|
| 277 |
+
for i, file in enumerate(md_files, start=1):
|
| 278 |
+
progress = (i / total_docs) * 100
|
| 279 |
+
print(
|
| 280 |
+
f"[{i}/{total_docs}] ({progress:.1f}%) "
|
| 281 |
+
f"Processing: {os.path.basename(file)}"
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
text = bu.load_md(file)
|
| 285 |
+
if not text.strip():
|
| 286 |
+
continue
|
| 287 |
+
|
| 288 |
+
doc_id = os.path.splitext(os.path.basename(file))[0]
|
| 289 |
+
frontmatter = bu.extract_frontmatter(text)
|
| 290 |
+
doc_title = frontmatter.get("title") or bu.extract_title_from_md(text, default=doc_id)
|
| 291 |
+
doc_authors = frontmatter.get("authors") if isinstance(frontmatter.get("authors"), list) else []
|
| 292 |
+
doc_pub_year = frontmatter.get("publication_year")
|
| 293 |
+
doc_pub_date = frontmatter.get("publication_date")
|
| 294 |
+
doc_topic = _normalize_frontmatter_topic(frontmatter.get("topic"))
|
| 295 |
+
if doc_topic is None:
|
| 296 |
+
doc_topic = _infer_topic_from_text(text)
|
| 297 |
+
|
| 298 |
+
chunks = text_splitter.create_documents([text])
|
| 299 |
+
if not chunks:
|
| 300 |
+
continue
|
| 301 |
+
|
| 302 |
+
batch_texts = [c.page_content for c in chunks]
|
| 303 |
+
|
| 304 |
+
needs_prefix = "e5" in (model_name or "").lower()
|
| 305 |
+
encode_texts = (
|
| 306 |
+
[f"passage: {t}" for t in batch_texts] if needs_prefix else batch_texts
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
batch_embs = retrieval_model.encode(
|
| 310 |
+
encode_texts,
|
| 311 |
+
convert_to_numpy=True,
|
| 312 |
+
normalize_embeddings=True
|
| 313 |
+
).astype("float32")
|
| 314 |
+
|
| 315 |
+
if index is None:
|
| 316 |
+
dim = batch_embs.shape[1]
|
| 317 |
+
index = faiss.IndexFlatIP(dim)
|
| 318 |
+
else:
|
| 319 |
+
if batch_embs.shape[1] != dim:
|
| 320 |
+
raise ValueError(
|
| 321 |
+
f"Embedding dim mismatch: expected {dim}, got {batch_embs.shape[1]}"
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
index.add(batch_embs)
|
| 325 |
+
|
| 326 |
+
for local_idx, content in enumerate(batch_texts):
|
| 327 |
+
meta_f.write(json.dumps({
|
| 328 |
+
"idx": global_idx,
|
| 329 |
+
"document_id": doc_id,
|
| 330 |
+
"document_title": doc_title,
|
| 331 |
+
"document_authors": doc_authors,
|
| 332 |
+
"publication_year": doc_pub_year,
|
| 333 |
+
"publication_date": doc_pub_date,
|
| 334 |
+
"topic": doc_topic,
|
| 335 |
+
"fragment_id": local_idx,
|
| 336 |
+
"content": content
|
| 337 |
+
}, ensure_ascii=False) + "\n")
|
| 338 |
+
global_idx += 1
|
| 339 |
+
|
| 340 |
+
if index is None or index.ntotal == 0:
|
| 341 |
+
raise RuntimeError("FAISS index is empty after processing.")
|
| 342 |
+
|
| 343 |
+
faiss.write_index(index, index_path)
|
| 344 |
+
|
| 345 |
+
index_info = {
|
| 346 |
+
"model_name": model_name,
|
| 347 |
+
"embedding_dim": dim,
|
| 348 |
+
"splitter": splitter,
|
| 349 |
+
"chunk_size": chunk_size,
|
| 350 |
+
"chunk_overlap": chunk_overlap,
|
| 351 |
+
"num_vectors": index.ntotal,
|
| 352 |
+
"num_documents_total": len(indexed_docs) + total_docs,
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
with open(index_info_path, "w", encoding="utf-8") as f:
|
| 356 |
+
json.dump(index_info, f, indent=2)
|
| 357 |
+
|
| 358 |
+
# -----------------------------
|
| 359 |
+
# Done
|
| 360 |
+
# -----------------------------
|
| 361 |
+
print("\nFAISS index update complete")
|
| 362 |
+
print(f" → New documents indexed : {total_docs}")
|
| 363 |
+
print(f" → Total vectors : {index.ntotal}")
|
| 364 |
+
print(f" → Index path : {index_path}")
|
utils/pdf_md.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
from markitdown import MarkItDown
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def clean_markdown_wrap_none(text: str) -> str:
|
| 7 |
+
"""
|
| 8 |
+
Joins all lines of each paragraph into a single line.
|
| 9 |
+
Preserves structure (headings, lists, code blocks).
|
| 10 |
+
"""
|
| 11 |
+
lines = text.splitlines()
|
| 12 |
+
out = []
|
| 13 |
+
buf = ""
|
| 14 |
+
|
| 15 |
+
bullet = re.compile(r"^(\s*[-*+]\s+|\s*\d+\.\s+)")
|
| 16 |
+
heading = re.compile(r"^\s{0,3}#{1,6}\s")
|
| 17 |
+
codefence = re.compile(r"^\s*```")
|
| 18 |
+
|
| 19 |
+
in_code = False
|
| 20 |
+
|
| 21 |
+
def flush():
|
| 22 |
+
nonlocal buf
|
| 23 |
+
if buf.strip():
|
| 24 |
+
out.append(buf.strip())
|
| 25 |
+
buf = ""
|
| 26 |
+
|
| 27 |
+
for raw in lines:
|
| 28 |
+
line = raw.rstrip("\n")
|
| 29 |
+
|
| 30 |
+
if codefence.match(line):
|
| 31 |
+
in_code = not in_code
|
| 32 |
+
flush()
|
| 33 |
+
out.append(line)
|
| 34 |
+
continue
|
| 35 |
+
|
| 36 |
+
if in_code:
|
| 37 |
+
out.append(line)
|
| 38 |
+
continue
|
| 39 |
+
|
| 40 |
+
if line.strip() == "":
|
| 41 |
+
flush()
|
| 42 |
+
out.append("")
|
| 43 |
+
continue
|
| 44 |
+
|
| 45 |
+
if heading.match(line) or bullet.match(line):
|
| 46 |
+
flush()
|
| 47 |
+
out.append(line)
|
| 48 |
+
continue
|
| 49 |
+
|
| 50 |
+
if not buf:
|
| 51 |
+
buf = line.strip()
|
| 52 |
+
continue
|
| 53 |
+
|
| 54 |
+
# Remove hyphen when word is split
|
| 55 |
+
if buf.endswith('-') and line.strip() and line.strip()[0].isalpha():
|
| 56 |
+
buf = buf[:-1] + line.strip()
|
| 57 |
+
else:
|
| 58 |
+
buf = buf + " " + line.strip()
|
| 59 |
+
|
| 60 |
+
flush()
|
| 61 |
+
return "\n".join(out)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def convert_document_to_markdown(
|
| 65 |
+
file_path: str,
|
| 66 |
+
output_path: str = None,
|
| 67 |
+
return_text: bool = True,
|
| 68 |
+
):
|
| 69 |
+
"""
|
| 70 |
+
Converts a single document (PDF/DOCX and other MarkItDown-supported types) to Markdown.
|
| 71 |
+
|
| 72 |
+
Parameters:
|
| 73 |
+
file_path (str): Path to input file
|
| 74 |
+
output_path (str, optional): Where to save the .md file
|
| 75 |
+
return_text (bool): If True, returns markdown text
|
| 76 |
+
|
| 77 |
+
Returns:
|
| 78 |
+
str | None: Markdown content (if return_text=True)
|
| 79 |
+
"""
|
| 80 |
+
if not os.path.isfile(file_path):
|
| 81 |
+
raise FileNotFoundError(f"File not found: {file_path}")
|
| 82 |
+
|
| 83 |
+
md = MarkItDown()
|
| 84 |
+
|
| 85 |
+
try:
|
| 86 |
+
result = md.convert(file_path)
|
| 87 |
+
clean_text = clean_markdown_wrap_none(result.text_content)
|
| 88 |
+
|
| 89 |
+
# Save file if requested
|
| 90 |
+
if output_path:
|
| 91 |
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
| 92 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 93 |
+
f.write(clean_text)
|
| 94 |
+
|
| 95 |
+
if return_text:
|
| 96 |
+
return clean_text
|
| 97 |
+
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
except Exception as e:
|
| 101 |
+
raise RuntimeError(f"Error converting file to Markdown: {e}")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def convert_pdf_to_markdown(
|
| 105 |
+
pdf_path: str,
|
| 106 |
+
output_path: str = None,
|
| 107 |
+
return_text: bool = True,
|
| 108 |
+
):
|
| 109 |
+
"""
|
| 110 |
+
Backward-compatible wrapper for PDF conversion.
|
| 111 |
+
"""
|
| 112 |
+
return convert_document_to_markdown(
|
| 113 |
+
file_path=pdf_path,
|
| 114 |
+
output_path=output_path,
|
| 115 |
+
return_text=return_text,
|
| 116 |
+
)
|
utils/retrieval_utils.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import os
|
| 3 |
+
import glob
|
| 4 |
+
import json
|
| 5 |
+
import pandas as pd
|
| 6 |
+
from sentence_transformers import SentenceTransformer
|
| 7 |
+
import numpy as np
|
| 8 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter, CharacterTextSplitter
|
| 9 |
+
from langchain_experimental.text_splitter import SemanticChunker
|
| 10 |
+
from langchain_community.embeddings import HuggingFaceEmbeddings
|
| 11 |
+
from . import base_utils as bu
|
| 12 |
+
|
| 13 |
+
def load_model(model_name):
|
| 14 |
+
return SentenceTransformer(model_name, device="cpu")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _model_needs_e5_prefix(model_name: str) -> bool:
|
| 18 |
+
"""Los modelos intfloat/e5-* requerem prefixos `query:` / `passage:`."""
|
| 19 |
+
return "e5" in (model_name or "").lower()
|
| 20 |
+
|
| 21 |
+
def get_text_splitter(splitter, chunk_size, chunk_overlap):
|
| 22 |
+
"""
|
| 23 |
+
Retrieve the appropriate text splitter based on a specified type.
|
| 24 |
+
"""
|
| 25 |
+
if splitter == "recursive":
|
| 26 |
+
return RecursiveCharacterTextSplitter(
|
| 27 |
+
chunk_size=chunk_size,
|
| 28 |
+
chunk_overlap=chunk_overlap,
|
| 29 |
+
length_function=len,
|
| 30 |
+
)
|
| 31 |
+
elif splitter == "tokens":
|
| 32 |
+
return CharacterTextSplitter.from_tiktoken_encoder(
|
| 33 |
+
encoding_name="cl100k_base",
|
| 34 |
+
chunk_size=chunk_size,
|
| 35 |
+
chunk_overlap=chunk_overlap,
|
| 36 |
+
)
|
| 37 |
+
elif splitter == "semantic":
|
| 38 |
+
embeddings_model = HuggingFaceEmbeddings(
|
| 39 |
+
model_name=bu.config["embeddings"]["model_name"])
|
| 40 |
+
return SemanticChunker(
|
| 41 |
+
embeddings=embeddings_model,
|
| 42 |
+
)
|
| 43 |
+
else:
|
| 44 |
+
return RecursiveCharacterTextSplitter(
|
| 45 |
+
chunk_size=chunk_size,
|
| 46 |
+
chunk_overlap=chunk_overlap,
|
| 47 |
+
length_function=len
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
def generate_embeddings(input_path, output_folder, model_name, splitter, chunk_size, chunk_overlap, retrieval_model, export_numpy=False, numpy_output_dir=None, max_files=None):
|
| 51 |
+
text_splitter = get_text_splitter(splitter, chunk_size, chunk_overlap)
|
| 52 |
+
md_files = glob.glob(input_path)
|
| 53 |
+
if not md_files:
|
| 54 |
+
print(f"No .md files found in path: {input_path}")
|
| 55 |
+
return
|
| 56 |
+
|
| 57 |
+
os.makedirs(output_folder, exist_ok=True)
|
| 58 |
+
|
| 59 |
+
emb_files = glob.glob(os.path.join(output_folder, "*.h5"))
|
| 60 |
+
for file in emb_files:
|
| 61 |
+
filename_without_ext = os.path.splitext(os.path.basename(file))[0]
|
| 62 |
+
corresponding_doc = os.path.join(os.path.dirname(input_path), filename_without_ext + ".md")
|
| 63 |
+
if not os.path.exists(corresponding_doc):
|
| 64 |
+
print(f"Embeddings file {file} has no corresponding .md. Deleting it.")
|
| 65 |
+
os.remove(file)
|
| 66 |
+
|
| 67 |
+
all_embeddings = []
|
| 68 |
+
all_metadata = []
|
| 69 |
+
global_idx = 0
|
| 70 |
+
|
| 71 |
+
if max_files is not None:
|
| 72 |
+
md_files = md_files[:max_files]
|
| 73 |
+
|
| 74 |
+
total_files = len(md_files)
|
| 75 |
+
for i, file in enumerate(md_files, start=1):
|
| 76 |
+
file_name = os.path.basename(file)
|
| 77 |
+
doc_id = os.path.splitext(file_name)[0]
|
| 78 |
+
output_file = os.path.join(output_folder, f"{doc_id}.h5")
|
| 79 |
+
|
| 80 |
+
if os.path.exists(output_file):
|
| 81 |
+
print(f"Embeddings already exists for {file_name}. Skipping generation and loading existing file for export...")
|
| 82 |
+
embeddings_df = pd.read_hdf(output_file, key="df")
|
| 83 |
+
else:
|
| 84 |
+
progress = (i / total_files) * 100
|
| 85 |
+
print(f"[{i}/{total_files}] ({progress:.1f}%) Generating embeddings for: {file_name}")
|
| 86 |
+
text = bu.load_md(file)
|
| 87 |
+
|
| 88 |
+
embeddings_list = []
|
| 89 |
+
content_list = []
|
| 90 |
+
|
| 91 |
+
if text.strip():
|
| 92 |
+
chunks = text_splitter.create_documents([text])
|
| 93 |
+
print(f"Chunks generated for document {file_name} : {len(chunks)}")
|
| 94 |
+
|
| 95 |
+
needs_prefix = _model_needs_e5_prefix(model_name)
|
| 96 |
+
for chunk in chunks:
|
| 97 |
+
chunk_text = chunk.page_content
|
| 98 |
+
input_text = (
|
| 99 |
+
f"passage: {chunk_text}" if needs_prefix else chunk_text
|
| 100 |
+
)
|
| 101 |
+
embedding = retrieval_model.encode(input_text)
|
| 102 |
+
embeddings_list.append(embedding)
|
| 103 |
+
content_list.append(chunk_text)
|
| 104 |
+
|
| 105 |
+
embeddings_df = pd.DataFrame(embeddings_list)
|
| 106 |
+
embeddings_df["segment_content"] = content_list
|
| 107 |
+
embeddings_df["model_name"] = model_name
|
| 108 |
+
embeddings_df["segment_content"] = embeddings_df["segment_content"].astype(str)
|
| 109 |
+
embeddings_df["model_name"] = embeddings_df["model_name"].astype(str)
|
| 110 |
+
|
| 111 |
+
embeddings_df.to_hdf(output_file, key="df", mode="w", format="table")
|
| 112 |
+
else:
|
| 113 |
+
embeddings_df = pd.DataFrame()
|
| 114 |
+
|
| 115 |
+
from . import base_utils as _bu_internal # import local para evitar ciclos en tiempo de carga
|
| 116 |
+
doc_title = _bu_internal.extract_title_from_md(text if 'text' in locals() else bu.load_md(file), default=file_name)
|
| 117 |
+
|
| 118 |
+
if export_numpy and not embeddings_df.empty:
|
| 119 |
+
emb_values = embeddings_df.iloc[:, :-2].values.astype("float32")
|
| 120 |
+
contents = embeddings_df["segment_content"].tolist()
|
| 121 |
+
|
| 122 |
+
for local_idx, (vec, content) in enumerate(zip(emb_values, contents)):
|
| 123 |
+
all_embeddings.append(vec)
|
| 124 |
+
all_metadata.append(
|
| 125 |
+
{
|
| 126 |
+
"idx": global_idx,
|
| 127 |
+
"document_id": doc_id,
|
| 128 |
+
"document_title": doc_title,
|
| 129 |
+
"fragment_id": local_idx,
|
| 130 |
+
"content": content,
|
| 131 |
+
}
|
| 132 |
+
)
|
| 133 |
+
global_idx += 1
|
| 134 |
+
|
| 135 |
+
if export_numpy and all_embeddings:
|
| 136 |
+
numpy_output_dir = numpy_output_dir or os.path.join("data", "embeddings")
|
| 137 |
+
os.makedirs(numpy_output_dir, exist_ok=True)
|
| 138 |
+
|
| 139 |
+
embeddings_array = np.vstack(all_embeddings).astype("float32")
|
| 140 |
+
np.save(os.path.join(numpy_output_dir, "embeddings.npy"), embeddings_array)
|
| 141 |
+
|
| 142 |
+
metadata_path = os.path.join(numpy_output_dir, "metadata.jsonl")
|
| 143 |
+
with open(metadata_path, "w", encoding="utf-8") as f:
|
| 144 |
+
for meta in all_metadata:
|
| 145 |
+
f.write(json.dumps(meta, ensure_ascii=False) + "\n")
|
| 146 |
+
|
| 147 |
+
print(f"Exported consolidated embeddings to {numpy_output_dir}")
|
| 148 |
+
|
| 149 |
+
def search_query(query, corpus_embeddings, retrieval_model, segment_contents):
|
| 150 |
+
|
| 151 |
+
query_embedding = retrieval_model.encode(query, convert_to_tensor=True)
|
| 152 |
+
similarity_scores = retrieval_model.similarity(query_embedding, corpus_embeddings)[0]
|
| 153 |
+
|
| 154 |
+
top_similarities, topk_indices = torch.topk(similarity_scores, k=bu.config['retrieve']['top_k'])
|
| 155 |
+
top_segments = [segment_contents[idx] for idx in topk_indices]
|
| 156 |
+
|
| 157 |
+
return top_segments, top_similarities
|
| 158 |
+
|
| 159 |
+
def load_embeddings(embeddings_dir):
|
| 160 |
+
embeddings_list = []
|
| 161 |
+
segment_contents_list = []
|
| 162 |
+
model_names_set = set()
|
| 163 |
+
|
| 164 |
+
num_documents = 0
|
| 165 |
+
for file_path in glob.glob(os.path.join(embeddings_dir, "*.h5")):
|
| 166 |
+
num_documents += 1
|
| 167 |
+
embeddings_df = pd.read_hdf(file_path, key='df')
|
| 168 |
+
embeddings = embeddings_df.iloc[:, :-2].values
|
| 169 |
+
segment_contents = embeddings_df['segment_content'].values
|
| 170 |
+
model_name = embeddings_df['model_name'].values[0]
|
| 171 |
+
|
| 172 |
+
embeddings_list.extend(embeddings)
|
| 173 |
+
segment_contents_list.extend(segment_contents)
|
| 174 |
+
model_names_set.add(model_name)
|
| 175 |
+
|
| 176 |
+
embeddings_array = np.array(embeddings_list)
|
| 177 |
+
embeddings_tensor = torch.tensor(embeddings_array, dtype=torch.float32, device='cuda' if torch.cuda.is_available() else 'cpu')
|
| 178 |
+
|
| 179 |
+
num_segment_contents = len(segment_contents_list)
|
| 180 |
+
model_name = model_names_set.pop() if len(model_names_set) == 1 else "Multiple Models"
|
| 181 |
+
|
| 182 |
+
return {
|
| 183 |
+
"embeddings": embeddings_tensor,
|
| 184 |
+
"segment_contents": segment_contents_list,
|
| 185 |
+
"num_documents": num_documents,
|
| 186 |
+
"num_segment_contents": num_segment_contents,
|
| 187 |
+
}
|
utils/retrievers.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
from typing import List, Dict, Any
|
| 4 |
+
|
| 5 |
+
import faiss
|
| 6 |
+
import numpy as np
|
| 7 |
+
#from elasticsearch import Elasticsearch
|
| 8 |
+
|
| 9 |
+
from . import base_utils as bu
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class BaseRetriever:
|
| 13 |
+
"""Interface base para mecanismos de recuperação.
|
| 14 |
+
|
| 15 |
+
A ideia é permitir trocar FAISS por Elasticsearch (ou outro backend)
|
| 16 |
+
sem mudar o restante da aplicação. Cada implementação deve expor um
|
| 17 |
+
método `retrieve` que recebe um vetor de consulta (1 x D) e devolve
|
| 18 |
+
uma lista de metadados de trechos no formato já usado pelo sistema.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
def retrieve(self, query_embedding: np.ndarray, top_k: int) -> List[Dict[str, Any]]:
|
| 22 |
+
raise NotImplementedError
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _load_index_and_metadata_from_config(config: dict):
|
| 26 |
+
"""Carrega índice FAISS e metadata consolidada a partir da config.
|
| 27 |
+
|
| 28 |
+
Mantém a mesma lógica que antes existia em `app/api_server.py`, mas
|
| 29 |
+
centralizada aqui para poder ser reutilizada por diferentes backends.
|
| 30 |
+
"""
|
| 31 |
+
index_path = config["index"].get("index_file", "data/index/faiss.index")
|
| 32 |
+
metadata_path = config["index"].get("metadata_file", "data/index/metadata.jsonl")
|
| 33 |
+
|
| 34 |
+
if not os.path.exists(index_path) or not os.path.exists(metadata_path):
|
| 35 |
+
raise FileNotFoundError(
|
| 36 |
+
"Index or metadata not found. Run scripts/build_index.py first."
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
index = faiss.read_index(index_path)
|
| 40 |
+
|
| 41 |
+
metadata: List[Dict[str, Any]] = []
|
| 42 |
+
with open(metadata_path, "r", encoding="utf-8") as f:
|
| 43 |
+
for line in f:
|
| 44 |
+
if line.strip():
|
| 45 |
+
metadata.append(json.loads(line))
|
| 46 |
+
|
| 47 |
+
return index, metadata
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class FaissRetriever(BaseRetriever):
|
| 51 |
+
"""Retriever baseado em índice FAISS local.
|
| 52 |
+
|
| 53 |
+
Usa `data/index/faiss.index` e `data/index/metadata.jsonl`, gerados
|
| 54 |
+
pelos scripts existentes (generate_embeddings + build_index).
|
| 55 |
+
"""
|
| 56 |
+
|
| 57 |
+
def __init__(self, config: dict) -> None:
|
| 58 |
+
self.config = config
|
| 59 |
+
self.index, self.metadata = _load_index_and_metadata_from_config(config)
|
| 60 |
+
|
| 61 |
+
# Mapa de idx global -> metadado, para lookup rápido durante a busca
|
| 62 |
+
self._meta_by_idx: Dict[int, Dict[str, Any]] = {}
|
| 63 |
+
for m in self.metadata:
|
| 64 |
+
idx = m.get("idx")
|
| 65 |
+
if idx is not None:
|
| 66 |
+
# Usamos uma cópia simples; o chamador pode depois copiar novamente
|
| 67 |
+
self._meta_by_idx[int(idx)] = m
|
| 68 |
+
|
| 69 |
+
def retrieve(self, query_embedding: np.ndarray, top_k: int) -> List[Dict[str, Any]]:
|
| 70 |
+
"""Busca vetorial usando FAISS e devolve metadados dos trechos."""
|
| 71 |
+
if query_embedding.ndim != 2:
|
| 72 |
+
raise ValueError("query_embedding must be a 2D array of shape (1, D)")
|
| 73 |
+
|
| 74 |
+
# Busca em FAISS (mesma lógica anterior)
|
| 75 |
+
scores, indices = self.index.search(query_embedding, top_k)
|
| 76 |
+
idxs = indices[0].tolist()
|
| 77 |
+
score_values = scores[0].tolist()
|
| 78 |
+
|
| 79 |
+
retrieved: List[Dict[str, Any]] = []
|
| 80 |
+
for rank, i in enumerate(idxs):
|
| 81 |
+
m = self._meta_by_idx.get(int(i))
|
| 82 |
+
if m is not None:
|
| 83 |
+
item = dict(m) # copiar para não vazar referência mutável
|
| 84 |
+
# Expõe score do FAISS para depuração/ranking no backend e scripts.
|
| 85 |
+
if rank < len(score_values):
|
| 86 |
+
item["score"] = float(score_values[rank])
|
| 87 |
+
# Garantir chaves esperadas para referências
|
| 88 |
+
item.setdefault("document_authors", [])
|
| 89 |
+
item.setdefault("publication_year", None)
|
| 90 |
+
item.setdefault("publication_date", None)
|
| 91 |
+
retrieved.append(item)
|
| 92 |
+
return retrieved
|
| 93 |
+
|
| 94 |
+
def list_documents(self) -> List[Dict[str, str]]:
|
| 95 |
+
"""Lista documentos únicos (id + título) com base na metadata carregada."""
|
| 96 |
+
docs: Dict[str, str] = {}
|
| 97 |
+
for m in self.metadata:
|
| 98 |
+
doc_id = m.get("document_id")
|
| 99 |
+
if not doc_id:
|
| 100 |
+
continue
|
| 101 |
+
titulo = m.get("document_title") or doc_id
|
| 102 |
+
if doc_id not in docs:
|
| 103 |
+
docs[doc_id] = titulo
|
| 104 |
+
|
| 105 |
+
documentos_ordenados = [
|
| 106 |
+
{"id": doc_id, "title": docs[doc_id]}
|
| 107 |
+
for doc_id in sorted(docs, key=lambda d: docs[d].lower())
|
| 108 |
+
]
|
| 109 |
+
return documentos_ordenados
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def get_retriever(config: dict) -> BaseRetriever:
|
| 113 |
+
"""
|
| 114 |
+
Fábrica simples para escolher o backend de recuperação.
|
| 115 |
+
"""
|
| 116 |
+
index_type = config.get("index", {}).get("type", "faiss").lower()
|
| 117 |
+
|
| 118 |
+
if index_type == "faiss":
|
| 119 |
+
return FaissRetriever(config)
|
| 120 |
+
|
| 121 |
+
if index_type == "elasticsearch":
|
| 122 |
+
return ElasticRetriever(config)
|
| 123 |
+
|
| 124 |
+
# Placeholder para futuras implementações.
|
| 125 |
+
raise ValueError(f"Index backend '{index_type}' not supported. Use 'faiss' or 'elasticsearch'.")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class ElasticRetriever(BaseRetriever):
|
| 129 |
+
"""
|
| 130 |
+
Retriever baseado em Elasticsearch (vector search).
|
| 131 |
+
"""
|
| 132 |
+
|
| 133 |
+
def __init__(self, config: dict) -> None:
|
| 134 |
+
self.config = config
|
| 135 |
+
idx_cfg = config.get("index", {})
|
| 136 |
+
|
| 137 |
+
self.host = idx_cfg.get("host", "http://localhost:9200")
|
| 138 |
+
self.index_name = idx_cfg.get("index_name", "chatbot-norm")
|
| 139 |
+
self.vector_field = idx_cfg.get("vector_field", "embedding")
|
| 140 |
+
self.api_key = idx_cfg.get("api_key") or os.getenv("ELASTIC_API_KEY")
|
| 141 |
+
self.username = idx_cfg.get("username")
|
| 142 |
+
self.password = idx_cfg.get("password")
|
| 143 |
+
|
| 144 |
+
# Cliente Elasticsearch (prioriza API key, depois basic_auth, depois sem auth)
|
| 145 |
+
if self.api_key:
|
| 146 |
+
self.client = Elasticsearch(self.host, api_key=self.api_key)
|
| 147 |
+
elif self.username and self.password:
|
| 148 |
+
self.client = Elasticsearch(self.host, basic_auth=(self.username, self.password))
|
| 149 |
+
else:
|
| 150 |
+
self.client = Elasticsearch(self.host)
|
| 151 |
+
|
| 152 |
+
def retrieve(self, query_embedding: np.ndarray, top_k: int) -> List[Dict[str, Any]]:
|
| 153 |
+
"""Executa busca vetorial k-NN em Elasticsearch."""
|
| 154 |
+
if query_embedding.ndim != 2:
|
| 155 |
+
raise ValueError("query_embedding must be a 2D array of shape (1, D)")
|
| 156 |
+
|
| 157 |
+
query_vec = query_embedding[0].astype(float).tolist()
|
| 158 |
+
num_candidates = max(top_k * 5, top_k)
|
| 159 |
+
|
| 160 |
+
knn_body = {
|
| 161 |
+
"field": self.vector_field,
|
| 162 |
+
"query_vector": query_vec,
|
| 163 |
+
"k": top_k,
|
| 164 |
+
"num_candidates": num_candidates,
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
resp = self.client.search(
|
| 168 |
+
index=self.index_name,
|
| 169 |
+
knn=knn_body,
|
| 170 |
+
size=top_k,
|
| 171 |
+
_source=[
|
| 172 |
+
"idx",
|
| 173 |
+
"document_id",
|
| 174 |
+
"document_title",
|
| 175 |
+
"document_authors",
|
| 176 |
+
"publication_year",
|
| 177 |
+
"publication_date",
|
| 178 |
+
"fragment_id",
|
| 179 |
+
"content",
|
| 180 |
+
],
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
hits = resp.get("hits", {}).get("hits", [])
|
| 184 |
+
retrieved: List[Dict[str, Any]] = []
|
| 185 |
+
for h in hits:
|
| 186 |
+
src = h.get("_source", {})
|
| 187 |
+
retrieved.append(
|
| 188 |
+
{
|
| 189 |
+
"idx": src.get("idx"),
|
| 190 |
+
"document_id": src.get("document_id"),
|
| 191 |
+
"document_title": src.get("document_title"),
|
| 192 |
+
"document_authors": src.get("document_authors"),
|
| 193 |
+
"publication_year": src.get("publication_year"),
|
| 194 |
+
"publication_date": src.get("publication_date"),
|
| 195 |
+
"fragment_id": src.get("fragment_id"),
|
| 196 |
+
"content": src.get("content"),
|
| 197 |
+
}
|
| 198 |
+
)
|
| 199 |
+
return retrieved
|
| 200 |
+
|
| 201 |
+
def list_documents(self) -> List[Dict[str, str]]:
|
| 202 |
+
"""Lista documentos únicos (id + título) a partir do índice ES.
|
| 203 |
+
|
| 204 |
+
Implementação simples via `match_all` limitada a 10k documentos.
|
| 205 |
+
Para bases muito maiores, seria melhor usar scroll / search_after.
|
| 206 |
+
"""
|
| 207 |
+
docs: Dict[str, str] = {}
|
| 208 |
+
|
| 209 |
+
resp = self.client.search(
|
| 210 |
+
index=self.index_name,
|
| 211 |
+
query={"match_all": {}},
|
| 212 |
+
size=10000,
|
| 213 |
+
_source=["document_id", "document_title"],
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
for h in resp.get("hits", {}).get("hits", []):
|
| 217 |
+
src = h.get("_source", {})
|
| 218 |
+
doc_id = src.get("document_id")
|
| 219 |
+
if not doc_id:
|
| 220 |
+
continue
|
| 221 |
+
titulo = src.get("document_title") or doc_id
|
| 222 |
+
if doc_id not in docs:
|
| 223 |
+
docs[doc_id] = titulo
|
| 224 |
+
|
| 225 |
+
documentos_ordenados = [
|
| 226 |
+
{"id": doc_id, "title": docs[doc_id]}
|
| 227 |
+
for doc_id in sorted(docs, key=lambda d: docs[d].lower())
|
| 228 |
+
]
|
| 229 |
+
return documentos_ordenados
|