GitHub Actions commited on
Commit
8aadaef
·
0 Parent(s):

Sync from GitHub 2026-04-30 19:20:11 UTC

Browse files
.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