# 🌐 Sources — Ingestión de Fuentes Externas
## 🤔 ¿Qué hago? ¿Cómo lo hago? ¿Y para qué lo hago?
**¿Qué hago?** Indexo fuentes de conocimiento externas en una colección Qdrant dedicada (`klaude_sources`), independiente de la caché semántica (`klaude_cache`) y de la knowledge base de proyecto (`klaude_knowledge`). Soporto tres tipos de fuente: páginas web, documentos Markdown y ficheros PDF.
**¿Cómo lo hago?** Cada fuente pasa por un pipeline idéntico:
1. **Extracción de texto** — vía Crawl4AI (web), descarga HTTP directa (markdown URL), **pypdf + OCR automático** (PDF) o texto directo (markdown inline)
- PDFs con texto nativo: pypdf
- PDFs escaneados (detectados automáticamente): PaddleOCR local (sin servicios externos)
2. **Chunking** — segmentación en fragmentos de ~800 caracteres con solapamiento de 80 caracteres para preservar contexto entre chunks
3. **Embedding** — fastembed `nomic-ai/nomic-embed-text-v1.5` (768 dimensiones), ya cargado en el proxy, sin llamada a servicio externo
4. **Upsert Qdrant** — almacenamiento con ID determinista (MD5 URL+índice) para evitar duplicados en re-indexación
**¿Para qué lo hago?** Amplio el corpus de conocimiento del proxy más allá del código del proyecto. Los usuarios pueden indexar documentación pública, RFCs, hojas de ruta o cualquier documento relevante. Cuando hay chunks relevantes en `klaude_sources`, el proxy los inyecta como contexto RAG en el system prompt y llama al LLM con `X-Cache: SOURCES-RAG`, enriqueciendo la respuesta con el contenido indexado.
---
## 🏗️ Arquitectura
```mermaid
flowchart TD
UI["🌐 Dashboard\nBotón Sources"] --> MW[Modal Sources\n3 tabs]
MW --> |Web URL| EP1["POST /sources/ingest/web"]
MW --> |Markdown| EP2["POST /sources/ingest/markdown"]
MW --> |PDF| EP3["POST /sources/ingest/pdf"]
EP1 --> C4AI["🕷️ Crawl4AI\nunclecode/crawl4ai:latest\n:11235"]
EP2 --> |URL raw| HTTP["httpx.AsyncClient"]
EP2 --> |texto directo| CHUNK
EP3 --> PYPDF["📋 pypdf\nPdfReader"]
C4AI --> MD["Markdown extraído"]
HTTP --> MD
PYPDF --> |¿Tiene texto?| CHECK{Detección}
CHECK --> |Sí <br/> (text)| TXT["Texto pypdf"]
CHECK --> |No <br/> (scanned)| OCR["🖼️ PaddleOCR\nlocal — todas las págs"]
OCR --> TXT
MD --> CHUNK["Chunker\n~800 chars\n80 overlap"]
TXT --> CHUNK
CHUNK --> EMB["fastembed\nnomic-embed-text-v1.5\n768d — en memoria"]
EMB --> QDRANT["🗄️ Qdrant\nklaude_sources"]
QDRANT --> STATS["GET /sources/stats"]
QDRANT --> FLUSH["DELETE /sources/flush"]
```
---
## 📡 Endpoints
### `POST /sources/ingest/web`
Crawlea una URL con Crawl4AI, extrae el markdown y lo indexa. Soporta modo **deep crawl** para indexar sitios de documentación completos siguiendo links internos (BFS).
**Headers:** `x-api-key: <cualquier valor no vacío>`
**Body (página única):**
```json
{ "url": "https://docs.anthropic.com/..." }
```
**Body (deep crawl — sitio completo):**
```json
{
"url": "https://docs.anthropic.com/",
"deep_crawl": true,
"max_pages": 50
}
```
| Parámetro | Tipo | Default | Descripción |
| --- | --- | --- | --- |
| `url` | string | — | URL de entrada (requerida) |
| `deep_crawl` | bool | `false` | Seguir links internos del mismo dominio (BFS) |
| `max_pages` | int | `200` | Máximo de páginas a crawlear (sin límite servidor — el BFS para solo cuando la cola se vacía) |
**Respuesta — página única (`deep_crawl: false`):**
```json
{
"stored": 8,
"total_chunks": 8,
"pages_crawled": 1,
"title": "Página",
"url": "https://docs.anthropic.com/..."
}
```
**Respuesta — deep crawl (`deep_crawl: true`): `202 Accepted`**
```json
{ "job_id": "550e8400-e29b-41d4-a716-446655440000", "status": "running" }
```
El deep crawl corre en background. Consultar progreso con `GET /sources/jobs/{job_id}`.
> 💡 Crawl4AI ejecuta un browser headless real — soporta páginas con JavaScript.
> ⚠️ En modo deep crawl el endpoint devuelve `202` inmediatamente — el crawl puede tardar varios minutos.
> ℹ️ El BFS extrae links `[text](url)` del markdown y solo sigue los del mismo dominio. Profundidad máxima: 3 niveles. Concurrencia: 3 páginas simultáneas.
---
### `POST /sources/ingest/markdown`
Indexa contenido Markdown. Acepta URL a un fichero `.md` raw o texto directo.
**Headers:** `x-api-key: <cualquier valor no vacío>`
**Body (URL):**
```json
{ "url": "https://raw.githubusercontent.com/org/repo/main/README.md" }
```
**Body (texto directo):**
```json
{ "content": "# Mi documento\n\nContenido markdown aquí..." }
```
**Ambos campos son opcionales pero al menos uno debe estar presente.**
**Respuesta:**
```json
{ "stored": 8, "total_chunks": 8, "title": "https://raw.github..." }
```
---
### `POST /sources/ingest/pdf`
Extrae texto de un PDF y lo indexa. Soporta **automáticamente** dos tipos de PDF:
| Tipo | Detección | Método |
| --- | --- | --- |
| **Texto nativo** | `pypdf` extrae >100 caracteres | pypdf (instantáneo) |
| **Escaneado** | `pypdf` extrae <100 caracteres | PaddleOCR local (procesa todas las págs) |
Upload multipart/form-data.
**Headers:** `x-api-key: <cualquier valor no vacío>`
**Body:** `multipart/form-data` con campo `file` (fichero `.pdf`)
**Respuesta (streaming SSE — texto):**
```json
{ "type": "init", "total": 25, "pages": 12, "title": "mi-documento.pdf", "extraction_method": "text" }
{ "type": "progress", "current": 1, "total": 25 }
...
{ "type": "done", "stored": 23, "total_chunks": 25, "title": "mi-documento.pdf", "pages": 12, "extraction_method": "text" }
```
**Respuesta (streaming SSE — OCR escaneado):**
```json
{ "type": "init", "total": 180, "pages": 150, "title": "documento-escaneado.pdf", "extraction_method": "ocr" }
...
{ "type": "done", "stored": 178, "total_chunks": 180, "title": "documento-escaneado.pdf", "pages": 150, "extraction_method": "ocr" }
```
**⏱️ Tiempos estimados:**
| Tipo | Páginas | Tiempo | Nota |
| --- | --- | --- | --- |
| Texto nativo | 50 | ~5 seg | instantáneo, solo embedding |
| Escaneado | 10 | ~1-2 min | OCR + embedding |
| Escaneado | 100 | ~10-15 min | OCR + embedding |
| Escaneado | 500+ | ~30-45 min | considera dividir el PDF |
> 📌 **OCR Automático**: Detectamos automáticamente si el PDF es escaneado (sin texto extraíble). Si lo es, activamos **PaddleOCR local** que procesa todas las páginas sin contactar servicios externos. Todo sucede en el contenedor del proxy.
>
> 💡 **Dependencias locales**: PaddleOCR es 100% local — no requiere APIs remotas. Usa `pdf2image` + `poppler-utils` para convertir PDF→imagen→OCR. En Podman/Docker, poppler-utils está preinstalado.
>
> ⚠️ **Calidad OCR**: Depende de la calidad de escaneo. OCR pobre genera chunks con errores tipográficos — el embedding aún así funciona correctamente.
>
> 🎯 **Limitaciones**: OCR es CPU-bound. PDFs muy largos (500+ págs) pueden tardar 30-45 minutos. Considera dividir PDFs grandes en múltiples uploads.
---
### `GET /sources/stats`
Estadísticas de la colección `klaude_sources`.
**Sin autenticación.**
**Respuesta:**
```json
{ "collection": "klaude_sources", "points_count": 145, "status": "Green" }
```
---
### `DELETE /sources/flush`
Vacía la colección (hard flush: borra y recrea).
**Headers:** `x-api-key: <cualquier valor no vacío>`
**Respuesta:**
```json
{ "flushed": true, "deleted_points": 145 }
```
---
## 🧩 Módulos
### `proxy/sources.py`
Módulo principal. Gestiona la colección `klaude_sources` en Qdrant.
| Función | Descripción |
| --- | --- |
| `chunk_text(text)` | Segmenta texto en chunks de ~800 chars con overlap |
| `ensure_collection()` | Crea la colección si no existe (llamado en lifespan) |
| `store_chunks(chunks, vectors, source_url, source_type, title)` | Upserta chunks con metadatos |
| `search(query_vector, top_k, threshold)` | Búsqueda semántica en klaude_sources |
| `get_stats()` | Estadísticas de la colección |
| `flush()` | Vacía y recrea la colección |
### `proxy/crawl4ai_client.py`
Cliente httpx async para el servicio Crawl4AI REST.
| Función | Descripción |
| --- | --- |
| `crawl_url(url, base, api_key)` | Crawlea URL y devuelve `{markdown, title}` |
Estrategia de fallback:
1. Intenta `/crawl/sync` (síncrono, disponible en 0.9.0+)
2. Si falla, usa `/crawl` con polling de `task_id` (compatible 0.6.x)
---
## 🗄️ Colección Qdrant: `klaude_sources`
| Campo payload | Tipo | Descripción |
| --- | --- | --- |
| `content` | string | Texto del chunk |
| `source_url` | string | URL de origen (o `pdf:filename` para PDFs) |
| `source_type` | string | `web`, `markdown`, `pdf` |
| `title` | string | Título de la página o nombre del fichero |
| `chunk_index` | int | Índice del chunk dentro de la fuente |
| `indexed_at` | ISO8601 | Timestamp de indexación |
**ID determinista:** `uuid.UUID(md5(f"{source_url}:{chunk_index}:{chunk[:32]}"))` — garantiza idempotencia en re-indexación de la misma fuente.
---
## 🐳 Infraestructura
### Servicio Crawl4AI en compose
```yaml
crawl4ai:
image: docker.io/unclecode/crawl4ai:latest
container_name: klaus-crawl4ai
mem_limit: 1g
environment:
CRAWL4AI_API_TOKEN: "${CRAWL4AI_API_TOKEN}" # requerido — sin él gunicorn escucha solo en loopback
ports:
- "11235:11235"
```
El proxy lo alcanza como `http://crawl4ai:11235` dentro de la red `klaus-net`.
### Variables de entorno
| Variable | Default | Descripción |
| --- | --- | --- |
| `CRAWL4AI_API_BASE` | `http://crawl4ai:11235` | URL base del servicio |
| `CRAWL4AI_API_TOKEN` | `` (vacío) | Token de auth para crawl4ai (mismo valor que el contenedor) |
| `SOURCES_SIMILARITY_THRESHOLD` | `0.75` | Umbral para búsqueda en sources |
---
## 🔒 Seguridad
- Todos los endpoints de escritura (`/ingest/*`, `/flush`) requieren header `x-api-key`
- El campo `x-api-key` acepta cualquier valor no vacío (sin validación de secreto específico — mismo comportamiento que el resto del proxy)
- Las URLs que se pasan a Crawl4AI no son validadas contra una allowlist — úsese con URLs de confianza
- Los PDFs se procesan en memoria; evitar PDFs malformados o excesivamente grandes (>50MB)
---
## 🔗 Documentos relacionados
- [knowledge_base.md](knowledge_base.md) — Knowledge Base de proyecto (colección `klaude_knowledge`)
- [cache.md](cache.md) — Caché semántica (colección `klaude_cache`)
- [architecture.md](architecture.md) — Arquitectura general del proxy
- [dashboard.md](dashboard.md) — Dashboard y controles de usuario