merge: quick/unit-tests-e2e-tables — tablas unit_tests y e2e_tests, parser de tests, docs

This commit is contained in:
2026-04-05 18:19:48 +02:00
76 changed files with 4423 additions and 2 deletions
+5
View File
@@ -58,9 +58,14 @@ sqlite3 registry.db ".schema"
**types** — columnas: `id, name, lang, domain, version, algebraic, definition, description, tags, uses_types, file_path, created_at, updated_at, examples, notes, documentation, code, content_hash, source_repo, source_license, source_file`
- Enums: `algebraic`(product|sum)
**unit_tests** — columnas: `id, function_id, name, code, file_path, lang, created_at, updated_at`
- Extraidos automaticamente por `fn index` desde los archivos de test
- FK: `function_id``functions.id`
**FTS5 (columnas buscables):**
- `functions_fts`: id, name, description, tags, signature, domain, example, notes, documentation, code
- `types_fts`: id, name, description, tags, domain, examples, notes, documentation, code
- `unit_tests_fts`: id, name, code, function_id, lang
---
+1 -1
View File
@@ -123,7 +123,7 @@ func cmdIndex() {
}
}
fmt.Printf("Indexed %d functions, %d types, %d apps, %d analysis\n", result.Functions, result.Types, result.Apps, result.Analysis)
fmt.Printf("Indexed %d functions, %d types, %d apps, %d analysis, %d unit_tests\n", result.Functions, result.Types, result.Apps, result.Analysis, result.UnitTests)
for _, e := range result.ValidationErrors {
fmt.Fprintf(os.Stderr, " INVALID: %s\n", e)
}
+67
View File
@@ -0,0 +1,67 @@
# Funciones a implementar desde OpenViking
Fuente: `sources/OpenViking` (ByteDance)
Licencia principal: **AGPL-3.0** (NO permisiva)
Licencia bot/: **MIT** | Licencia examples/: **Apache 2.0**
**Estrategia:** No copiar codigo. Reimplementar desde cero las funcionalidades
genericas y utiles, documentadas aqui como specs independientes. Las funciones
resultantes seran originales, sin dependencia alguna del codigo AGPL.
## Funciones
| # | Archivo | Dominio | Funciones |
|---|---------|---------|-----------|
| 01 | parse_markdown.md | core | extract_frontmatter, find_headings, smart_split_content, estimate_token_count, sanitize_for_path |
| 02 | parse_pdf_to_markdown.md | core | pdf_to_markdown, extract_pdf_bookmarks, detect_headings_by_font, format_table_to_markdown |
| 03 | parse_html_to_markdown.md | core | html_to_markdown, detect_url_type, fetch_and_parse_url, convert_github_to_raw_url |
| 04 | parse_docx_to_markdown.md | core | docx_to_markdown |
| 05 | parse_excel_to_markdown.md | core | excel_to_markdown |
| 06 | parse_epub_to_markdown.md | core | epub_to_markdown |
| 07 | directory_scanner.md | infra | scan_directory |
| 08 | circuit_breaker.md | core | CircuitBreaker (class) |
| 09 | retry_with_classify.md | core | classify_api_error, compute_backoff_delay, retry_sync, retry_async |
| 10 | hotness_score.md | datascience | hotness_score |
| 11 | time_utils.md | core | parse_iso_datetime, format_iso8601, format_simplified |
| 12 | safe_extract_zip.md | infra | safe_extract_zip, normalize_zip_filenames |
| 13 | envelope_encryption.md | cybersecurity | envelope_encrypt, envelope_decrypt |
| 14 | parse_code_ast.md | core | parse_code_ast (tree-sitter multi-language) |
| 15 | git_url_parser.md | core | parse_git_url, is_git_repo_url, validate_git_ssh_uri |
| 16 | media_strategy.md | core | calculate_media_strategy |
| 17 | parser_registry.md | core | ParserRegistry (patron extensible) |
| 18 | read_file_with_encoding.md | infra | read_file_with_encoding |
## Tipos
| # | Archivo | Dominio | Tipos |
|---|---------|---------|-------|
| 19 | type_parse_result.md | core | NodeType (sum), ResourceNode (product), ParseResult (product) |
| 20 | type_classified_file.md | infra | ClassifiedFile (product), DirectoryScanResult (product) |
| 21 | type_code_entity.md | core | CodeEntity (product) |
| 22 | type_message.md | core | TextPart, ContextPart, ToolPart, Part (sum), Message (product) |
| 23 | type_retrieval.md | core | ContextType (sum), TypedQuery, QueryPlan, MatchedContext, ScoreDistribution, QueryResult, FindResult |
| 24 | type_memory.md | core | FieldType (sum), MergeOp (sum), MemoryField, MemoryTypeSchema, MemoryData |
| 25 | type_context.md | core | ResourceContentType (sum), ContextLevel (sum), Context (product) |
## Gaps identificados (specs propias)
Funciones que faltan en el registry para completar capacidades de ingesta,
operaciones y automatizacion. Diseño original, sin fuente externa.
### Funciones
| # | Archivo | Dominio | Funciones |
|---|---------|---------|-----------|
| 26 | validation_schemas.md | core | validate_json_schema, validate_struct_fields, coerce_types |
| 27 | tabular_transforms.md | datascience/core | pivot, melt, join_by_key, aggregate_by_group |
| 28 | serialization_format.md | core | to_csv, from_csv, to_jsonl, from_jsonl, render_template, generate_html_report |
| 29 | http_client.md | infra | http_get_json, http_post_json, http_download_file |
| 30 | scheduling.md | core/infra | parse_cron_expr, next_cron_time, cron_ticker |
| 31 | cache_persistent.md | infra/core | cache_to_sqlite, cache_to_file, cache_decorator |
| 32 | diff_merge.md | datascience | diff_entities, diff_relations, detect_drift, merge_graphs |
### Tipos
| # | Archivo | Dominio | Tipos |
|---|---------|---------|-------|
| 30 | scheduling.md | core | CronSchedule (product) |
@@ -0,0 +1,80 @@
# Parse Markdown — Suite de funciones
Fuente conceptual: OpenViking `openviking/parse/parsers/markdown.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funciones a implementar
### 1. extract_frontmatter
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `extract_frontmatter(content: str) -> tuple[str, dict | None]`
- **Descripcion:** Extrae YAML frontmatter (delimitado por `---`) del inicio de un string markdown. Retorna el contenido sin frontmatter y el dict parseado (o None si no hay).
- **Algoritmo:**
1. Regex `^---\n(.*?)\n---\n` con DOTALL
2. Si match, parsear cada linea `key: value` (o usar `yaml.safe_load`)
3. Retornar (contenido_restante, dict_frontmatter)
- **Deps:** `re`, opcionalmente `yaml`
- **Tests:** contenido con frontmatter, sin frontmatter, frontmatter vacio, frontmatter con listas
### 2. find_headings
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `find_headings(content: str) -> list[tuple[int, int, str, int]]`
- **Descripcion:** Encuentra todos los headings markdown (# a ######), excluyendo los que estan dentro de code blocks (``` ... ```), HTML comments (<!-- ... -->) y bloques indentados.
- **Retorno:** Lista de `(start_pos, end_pos, title, level)`
- **Algoritmo:**
1. Recopilar rangos excluidos: code blocks (triple backtick), HTML comments, bloques indentados (4 espacios/tab)
2. Regex `^(#{1,6})\s+(.+)$` con MULTILINE
3. Filtrar matches cuya posicion cae en un rango excluido
4. Filtrar headings escapados (`\#`)
- **Deps:** `re`
- **Tests:** headings normales, headings dentro de code blocks (no deben detectarse), headings escapados, headings en HTML comments
### 3. smart_split_content
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `smart_split_content(content: str, max_tokens: int = 1024, max_chars: int = 8000) -> list[str]`
- **Descripcion:** Divide contenido grande en partes respetando limites de tokens y caracteres. Divide por parrafos (doble newline). Si un parrafo individual excede el limite, lo corta por caracteres.
- **Algoritmo:**
1. Split por `\n\n` (parrafos)
2. Acumular parrafos mientras no excedan max_tokens/max_chars
3. Si un parrafo individual excede, cortarlo en chunks de max_chars
4. Retornar lista de partes
- **Deps:** `re`
- **Tests:** contenido corto (1 parte), contenido largo, parrafo gigante que requiere forzar corte
### 4. estimate_token_count
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `estimate_token_count(content: str) -> int`
- **Descripcion:** Estimacion rapida de tokens sin tokenizer. CJK chars ~0.7 token/char, otros ~0.3 token/char.
- **Algoritmo:**
1. Contar chars CJK con regex `[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]`
2. Contar otros non-whitespace chars
3. Retornar `int(cjk * 0.7 + otros * 0.3)`
- **Deps:** `re`
- **Tests:** texto solo latin, texto solo CJK, texto mixto, texto vacio
### 5. sanitize_for_path
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `sanitize_for_path(text: str, max_length: int = 50) -> str`
- **Descripcion:** Convierte texto a nombre seguro para uso en paths. Remueve caracteres especiales, reemplaza espacios con `_`, trunca con hash suffix si excede max_length.
- **Algoritmo:**
1. Regex: mantener solo `\w`, CJK ranges, espacios y guiones
2. Reemplazar espacios por `_`, strip underscores
3. Si vacio, retornar "section"
4. Si excede max_length: truncar y anadir `_` + sha256[:8]
- **Deps:** `re`, `hashlib`
- **Tests:** texto normal, texto con caracteres especiales, texto muy largo, texto vacio, texto CJK
@@ -0,0 +1,74 @@
# Parse PDF to Markdown
Fuente conceptual: OpenViking `openviking/parse/parsers/pdf.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funciones a implementar
### 1. pdf_to_markdown
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (I/O: lee archivo PDF)
- **Signature:** `pdf_to_markdown(pdf_path: str, heading_detection: str = "auto") -> tuple[str, dict]`
- **Descripcion:** Convierte un PDF a markdown. Extrae texto, tablas e inyecta headings detectados desde bookmarks o analisis de fuentes. Retorna (markdown_content, metadata_dict).
- **Algoritmo:**
1. Abrir PDF con pdfplumber
2. Detectar headings:
- `"bookmarks"`: extraer outlines/bookmarks del PDF
- `"font"`: analizar distribucion de font sizes para detectar headings
- `"auto"`: intentar bookmarks primero, fallback a font
3. Agrupar headings por pagina
4. Por cada pagina: inyectar headings como `# titulo`, extraer texto, extraer tablas como markdown
5. Unir todo con `\n\n`
- **Deps:** `pdfplumber`
- **Error type:** Exception (archivo no encontrado, PDF corrupto)
- **Tests:** PDF con bookmarks, PDF sin bookmarks, PDF con tablas, PDF vacio
### 2. extract_pdf_bookmarks
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (requiere objeto PDF abierto)
- **Signature:** `extract_pdf_bookmarks(pdf) -> list[dict]`
- **Descripcion:** Extrae la estructura de bookmarks/outlines de un PDF abierto con pdfplumber. Retorna lista de `{"level": int, "title": str, "page_num": int | None}`.
- **Algoritmo:**
1. Acceder a `pdf.doc.get_outlines()`
2. Construir mapping `objid -> page_number` desde pdf.pages
3. Resolver cada destino de outline a su numero de pagina
4. Limitar nivel a [1, 6]
- **Deps:** `pdfplumber`
- **Tests:** PDF con outlines, PDF sin outlines
### 3. detect_headings_by_font
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (requiere objeto PDF abierto)
- **Signature:** `detect_headings_by_font(pdf, min_delta: float = 2.0, max_levels: int = 4) -> list[dict]`
- **Descripcion:** Detecta headings analizando la distribucion de font sizes. El font size mas comun es el body; sizes significativamente mayores son headings.
- **Algoritmo:**
1. Samplear font sizes (cada 5ta pagina) → Counter
2. Determinar body_size (most_common)
3. Heading sizes: font sizes >= body_size + min_delta Y con frecuencia < 50% del body
4. Ordenar heading sizes desc → asignar niveles 1,2,3...
5. Recorrer todas las paginas, extraer texto de chars con heading size
6. Deduplicar: filtrar titulos que aparecen en >30% de paginas (son headers/footers)
- **Deps:** `pdfplumber`, `collections.Counter`
- **Tests:** PDF con headings claros por font, PDF con fuente uniforme (sin headings detectados)
### 4. format_table_markdown (reutilizable)
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `format_table_to_markdown(rows: list[list[str]], has_header: bool = True) -> str`
- **Descripcion:** Convierte una lista 2D de celdas a tabla markdown con alineacion de columnas.
- **Algoritmo:**
1. Calcular max width por columna
2. Formatear header row con `|`
3. Anadir separador `| --- | --- |`
4. Formatear data rows
5. Escapar pipes en celdas
- **Deps:** ninguna
- **Tests:** tabla normal, tabla con celdas vacias, tabla con 1 fila, tabla vacia, celdas con pipes
@@ -0,0 +1,66 @@
# Parse HTML to Markdown
Fuente conceptual: OpenViking `openviking/parse/parsers/html.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funciones a implementar
### 1. html_to_markdown
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `html_to_markdown(html: str) -> str`
- **Descripcion:** Convierte HTML a markdown. Usa readabilipy para extraer contenido principal (filtra nav, ads, boilerplate), luego markdownify para convertir a markdown.
- **Algoritmo:**
1. Preprocesar HTML: manejar contenido oculto (ej: WeChat js_content), lazy loading images (data-src → src)
2. Extraer contenido principal con readabilipy (basado en Mozilla Readability)
3. Convertir a markdown con markdownify (headings ATX, strip script/style)
- **Deps:** `readabilipy`, `markdownify`, `beautifulsoup4`
- **Tests:** HTML con nav/footer (debe filtrarse), HTML limpio, HTML con imagenes lazy-loaded
### 2. detect_url_type
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (hace HTTP HEAD request)
- **Signature:** `detect_url_type(url: str, timeout: float = 10.0) -> tuple[str, dict]`
- **Descripcion:** Detecta el tipo de contenido de una URL. Retorna tipo ("webpage", "pdf", "markdown", "text", "code_repository") y metadata.
- **Algoritmo:**
1. Verificar patrones de repos de codigo (github.com/org/repo, git@...)
2. Verificar extension en URL (.pdf, .md, .txt, .html, .git)
3. Si no se determino: HTTP HEAD request → leer Content-Type header
4. Default: "webpage"
- **Deps:** `httpx`, `urllib.parse`
- **Error type:** Exception (timeout, network error)
- **Tests:** URL .pdf, URL github repo, URL webpage, URL con Content-Type custom
### 3. fetch_and_parse_url
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (I/O: HTTP request)
- **Signature:** `fetch_and_parse_url(url: str, timeout: float = 30.0) -> str`
- **Descripcion:** Descarga una pagina web y la convierte a markdown. Combina fetch HTML + html_to_markdown.
- **Algoritmo:**
1. Detectar tipo de URL con detect_url_type
2. Si webpage: fetch HTML con httpx, convertir a markdown
3. Si download link: descargar a archivo temporal, delegar al parser apropiado
4. Si code repository: delegar a parser de codigo
- **Deps:** `httpx`
- **Error type:** Exception
### 4. convert_github_to_raw_url
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `convert_github_to_raw_url(url: str) -> str`
- **Descripcion:** Convierte una URL de blob de GitHub/GitLab a su URL raw. Ej: `github.com/org/repo/blob/main/file.py``raw.githubusercontent.com/org/repo/main/file.py`
- **Algoritmo:**
1. Parsear URL
2. Si GitHub y path contiene `/blob/`: remover `/blob/` y cambiar dominio a raw.githubusercontent.com
3. Si GitLab y path contiene `/blob/`: reemplazar por `/raw/`
4. Si no aplica, retornar URL sin cambiar
- **Deps:** `urllib.parse`
- **Tests:** URL GitHub blob, URL GitLab blob, URL que no es blob, URL no-GitHub
@@ -0,0 +1,30 @@
# Parse DOCX to Markdown
Fuente conceptual: OpenViking `openviking/parse/parsers/word.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funcion a implementar
### docx_to_markdown
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (I/O: lee archivo .docx)
- **Signature:** `docx_to_markdown(docx_path: str) -> str`
- **Descripcion:** Convierte un documento Word (.docx) a markdown preservando estructura, formato y tablas en su posicion original.
- **Algoritmo:**
1. Abrir con python-docx
2. Construir mapa de tablas: `{element_xml: Table}` para lookup O(1)
3. Recorrer `doc.element.body` en orden (preserva posicion de tablas):
- Si es parrafo (`w:p`):
- Si estilo es Heading N: `{'#' * level} {text}`
- Si no: convertir runs con formato (bold→`**`, italic→`*`, underline→`<ins>`)
- Si es tabla (`w:tbl`): convertir filas a markdown table con `format_table_to_markdown`
4. Unir partes con `\n\n`
- **Deps:** `python-docx`
- **Error type:** Exception (archivo no encontrado, formato invalido)
- **Notas:**
- Las tablas deben aparecer en su posicion original, no al final
- Los heading levels se extraen del nombre del estilo ("Heading 1" → nivel 1)
- El formato inline (bold/italic/underline) se preserva
- **Tests:** docx con headings y parrafos, docx con tablas intercaladas, docx con formato bold/italic, docx vacio
@@ -0,0 +1,32 @@
# Parse Excel to Markdown
Fuente conceptual: OpenViking `openviking/parse/parsers/excel.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funcion a implementar
### excel_to_markdown
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (I/O: lee archivo)
- **Signature:** `excel_to_markdown(path: str, max_rows_per_sheet: int = 1000) -> str`
- **Descripcion:** Convierte un archivo Excel (.xlsx, .xls, .xlsm) a markdown con cada sheet como seccion H2.
- **Algoritmo:**
1. Si extension `.xls`: usar xlrd (legacy)
2. Si `.xlsx`/`.xlsm`: usar openpyxl
3. Para cada sheet:
- Header: `## Sheet: {name}`
- Metadata: `**Dimensions:** {rows} x {cols}`
- Truncar a max_rows_per_sheet
- Convertir filas a tabla markdown
- Si truncado: anadir nota de filas omitidas
4. Manejo de tipos de celda para xlrd:
- EMPTY/BLANK → ""
- DATE → formato ISO (con hora si no es 00:00:00)
- BOOLEAN → "TRUE"/"FALSE"
- ERROR → codigo de error Excel (#NULL!, #DIV/0!, etc.)
- NUMBER → entero si es entero, float si tiene decimales
- **Deps:** `openpyxl` (xlsx), `xlrd` (xls legacy)
- **Error type:** Exception
- **Tests:** xlsx con multiples sheets, xls legacy con fechas, sheet vacio, sheet con formulas (data_only), sheet truncado
@@ -0,0 +1,36 @@
# Parse EPUB to Markdown
Fuente conceptual: OpenViking `openviking/parse/parsers/epub.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funcion a implementar
### epub_to_markdown
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (I/O: lee archivo)
- **Signature:** `epub_to_markdown(epub_path: str) -> str`
- **Descripcion:** Convierte un ebook EPUB a markdown. Intenta ebooklib primero, fallback a extraccion manual con zipfile.
- **Algoritmo con ebooklib:**
1. Leer con `epub.read_epub(path)`
2. Extraer metadata: titulo, autor
3. Generar header: `# {titulo}` + `**Author:** {autor}`
4. Para cada ITEM_DOCUMENT: decodificar contenido HTML → convertir a markdown
5. Unir con `\n\n`
- **Algoritmo fallback manual (sin ebooklib):**
1. Abrir como ZIP
2. Listar archivos .html/.xhtml/.htm
3. Para cada uno: decodificar HTML → convertir a markdown basico
- **Conversion HTML basica a markdown:**
1. Remover `<script>` y `<style>` tags
2. `<h1>``# ...`, `<h2>``## ...`, etc.
3. `<strong>`/`<b>``**...**`, `<em>`/`<i>``*...*`
4. `<p>` → contenido + `\n\n`
5. `<br>``\n`
6. Remover tags HTML restantes
7. Unescape HTML entities (`&amp;``&`)
8. Normalizar whitespace
- **Deps:** `ebooklib` (opcional), `zipfile` (stdlib)
- **Error type:** Exception
- **Tests:** epub con ebooklib, epub sin ebooklib (manual), epub con metadata, epub corrupto
@@ -0,0 +1,61 @@
# Directory Scanner
Fuente conceptual: OpenViking `openviking/parse/directory_scan.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funciones a implementar
### 1. scan_directory
- **Dominio:** infra
- **Lang:** Python
- **Purity:** impure (I/O: filesystem traversal)
- **Signature:** `scan_directory(root: str, supported_extensions: set[str] | None = None, ignore_dirs: set[str] | None = None, include: str | None = None, exclude: str | None = None, strict: bool = False) -> DirectoryScanResult`
- **Descripcion:** Recorre un arbol de directorios y clasifica cada archivo como procesable o no soportado. Util para validacion pre-importacion de directorios.
- **Tipos de retorno:**
```python
@dataclass
class ClassifiedFile:
path: str # path absoluto
rel_path: str # relativo a root, forward slashes
classification: str # "processable" | "unsupported"
@dataclass
class DirectoryScanResult:
root: str
processable: list[ClassifiedFile]
unsupported: list[ClassifiedFile]
skipped: list[str] # "path (reason)"
warnings: list[str]
```
- **Algoritmo:**
1. Validar que root existe y es directorio
2. `os.walk(root, topdown=True)` para recorrer
3. Podar directorios:
- Skip: dot dirs, symlinks, IGNORE_DIRS predefinidos (`__pycache__`, `node_modules`, `.git`, `venv`, etc.)
- Skip: dirs en `ignore_dirs` (por nombre o por path relativo)
4. Por cada archivo:
- Skip: dot files, symlinks, archivos vacios
- Aplicar filtros include/exclude (glob patterns, comma-separated)
- Clasificar: extension en supported_extensions → processable, sino → unsupported
5. Si strict=True y hay unsupported: raise error
6. Ordenar resultados
- **Logica de include/exclude:**
- include: comma-separated globs (ej: `"*.pdf,*.md"`), si vacio incluye todo
- exclude: comma-separated, pattern con `/` final es path prefix (ej: `"drafts/"`), sin `/` es glob de nombre (ej: `"*.tmp"`)
- **Deps:** `os`, `pathlib`, `fnmatch`, `dataclasses`
- **Error type:** FileNotFoundError, NotADirectoryError
- **Tests:** directorio con mezcla de archivos, directorio con dot files, directorio con subdirs ignorados, filtros include/exclude, modo strict
### 2. Constantes IGNORE_DIRS sugeridas
```python
IGNORE_DIRS = {
"__pycache__", "node_modules", ".git", ".svn", ".hg",
"venv", ".venv", "env", ".env", ".tox", ".nox",
".mypy_cache", ".pytest_cache", ".ruff_cache",
"dist", "build", ".next", ".nuxt",
"target", # Rust/Java
"vendor", # Go
}
```
@@ -0,0 +1,38 @@
# Circuit Breaker
Fuente conceptual: OpenViking `openviking/utils/circuit_breaker.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Clase a implementar
### CircuitBreaker
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (tiene estado mutable + usa time.monotonic)
- **Signature:**
```python
class CircuitBreaker:
def __init__(self, failure_threshold: int = 5, reset_timeout: float = 300.0): ...
def check(self) -> None: ... # raises CircuitBreakerOpen
def record_success(self) -> None: ...
def record_failure(self, error: Exception) -> None: ...
@property
def retry_after(self) -> float: ... # seconds until half-open, capped at 30s
```
- **Descripcion:** Patron circuit breaker thread-safe para proteger llamadas a APIs externas. Tres estados: CLOSED (normal), OPEN (bloqueando), HALF_OPEN (permitiendo 1 request de prueba).
- **Algoritmo:**
- **CLOSED:** Requests pasan normalmente. Si fallan `failure_threshold` veces consecutivas → OPEN. Errores permanentes (401, 403) abren inmediatamente.
- **OPEN:** Requests bloqueados con `CircuitBreakerOpen`. Despues de `reset_timeout` segundos → HALF_OPEN.
- **HALF_OPEN:** Permite 1 request de prueba. Si exito → CLOSED. Si falla → OPEN.
- **Thread safety:** Usar `threading.Lock` para proteger estado interno.
- **Clasificacion de errores:** Integrar con `classify_api_error()` (ver spec 09) para distinguir errores permanentes de transitorios.
- **Deps:** `threading`, `time`
- **Tests:**
- Transicion CLOSED → OPEN despues de N fallos
- Transicion OPEN → HALF_OPEN despues de timeout
- Transicion HALF_OPEN → CLOSED en exito
- Transicion HALF_OPEN → OPEN en fallo
- Error permanente abre inmediatamente
- Thread safety (concurrencia)
- retry_after retorna 0 cuando no esta OPEN
@@ -0,0 +1,57 @@
# Retry with Error Classification
Fuente conceptual: OpenViking `openviking/utils/model_retry.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funciones a implementar
### 1. classify_api_error
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `classify_api_error(error: Exception) -> str`
- **Retorno:** `"permanent"` | `"transient"` | `"unknown"`
- **Descripcion:** Clasifica un error de API como permanente (no reintentar), transitorio (reintentar) o desconocido.
- **Algoritmo:**
1. Obtener textos: `str(error)` + `str(error.__cause__)` si existe
2. Buscar patrones permanentes: `"400"`, `"401"`, `"403"`, `"Forbidden"`, `"Unauthorized"`
3. Buscar patrones transitorios: `"429"`, `"500"`, `"502"`, `"503"`, `"504"`, `"TooManyRequests"`, `"RateLimit"`, `"timeout"`, `"Timeout"`, `"ConnectionError"`, `"Connection refused"`, `"Connection reset"`
4. Permanente tiene prioridad sobre transitorio
- **Deps:** ninguna
- **Tests:** error 429, error 401, error timeout, error desconocido, error con __cause__
### 2. compute_backoff_delay
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure (con jitter usa random pero es aceptable)
- **Signature:** `compute_backoff_delay(attempt: int, base_delay: float = 0.5, max_delay: float = 8.0, jitter: bool = True) -> float`
- **Descripcion:** Calcula el delay para exponential backoff con jitter opcional.
- **Algoritmo:**
1. `delay = min(base_delay * 2^attempt, max_delay)`
2. Si jitter: `delay += random.uniform(0, min(base_delay, delay))`
- **Deps:** `random`
- **Tests:** attempt 0 (base_delay), attempt alto (capped a max_delay), sin jitter (determinista)
### 3. retry_sync
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (ejecuta funcion, hace sleep)
- **Signature:** `retry_sync(func: Callable[[], T], max_retries: int, base_delay: float = 0.5, max_delay: float = 8.0, jitter: bool = True, is_retryable: Callable[[Exception], bool] | None = None) -> T`
- **Descripcion:** Reintenta una funcion sincrona en errores transitorios con exponential backoff.
- **Algoritmo:**
1. Loop: llamar func()
2. Si excepcion Y es retryable Y no agotamos intentos: dormir delay, incrementar attempt
3. Si no retryable o agotamos intentos: re-raise
- **Deps:** `time`
### 4. retry_async
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure
- **Signature:** `async retry_async(func: Callable[[], Awaitable[T]], max_retries: int, base_delay: float = 0.5, max_delay: float = 8.0, jitter: bool = True, is_retryable: Callable[[Exception], bool] | None = None) -> T`
- **Descripcion:** Version async de retry_sync. Usa `asyncio.sleep` en vez de `time.sleep`.
- **Deps:** `asyncio`
@@ -0,0 +1,41 @@
# Hotness Score
Fuente conceptual: OpenViking `openviking/retrieve/memory_lifecycle.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funcion a implementar
### hotness_score
- **Dominio:** datascience
- **Lang:** Python
- **Purity:** pure (si se pasa `now` explicitamente)
- **Signature:** `hotness_score(active_count: int, updated_at: datetime | None, now: datetime | None = None, half_life_days: float = 7.0) -> float`
- **Retorno:** float en [0.0, 1.0]
- **Descripcion:** Calcula un score de "hotness" (calor/relevancia) combinando frecuencia de acceso y recencia. Util para ranking de resultados de busqueda, memoria hot/cold, cache eviction.
- **Formula:**
```
score = sigmoid(log1p(active_count)) * time_decay(updated_at)
```
- **Algoritmo:**
1. **Componente de frecuencia:** `freq = 1 / (1 + exp(-log1p(active_count)))` — sigmoid de log1p mapea conteos a (0,1)
2. **Componente de recencia:** Si updated_at es None → retornar 0.0
- Normalizar a UTC
- `age_days = max((now - updated_at).total_seconds() / 86400, 0)`
- `decay_rate = ln(2) / half_life_days`
- `recency = exp(-decay_rate * age_days)`
3. **Score final:** `freq * recency`
- **Propiedades:**
- active_count=0, cualquier fecha → ~0.5 * recency
- active_count alto, reciente → cercano a 1.0
- active_count alto, antiguo → cercano a 0.0
- updated_at=None → siempre 0.0
- half_life_days controla velocidad de decaimiento (7 = score se reduce a la mitad cada 7 dias)
- **Deps:** `math`, `datetime`
- **Tests:**
- active_count=0, updated_at reciente
- active_count=100, updated_at reciente (score alto)
- active_count=100, updated_at hace 30 dias (score bajo)
- updated_at=None (retorna 0.0)
- now explicito (determinista para tests)
- half_life_days custom
@@ -0,0 +1,48 @@
# Time Utils
Fuente conceptual: OpenViking `openviking/utils/time_utils.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funciones a implementar
### 1. parse_iso_datetime
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `parse_iso_datetime(value: str) -> datetime`
- **Descripcion:** Parsea un datetime ISO 8601 tolerando >6 digitos en fracciones de segundo (Windows produce timestamps como `2026-02-21T13:20:23.1470042+08:00` donde los microsegundos exceden 6 digitos de Python).
- **Algoritmo:**
1. Regex: truncar fracciones de segundo a 6 digitos: `(\.\d{6})\d+``\1`
2. Reemplazar `Z` final por `+00:00`
3. `datetime.fromisoformat(normalized)`
- **Deps:** `re`, `datetime`
- **Tests:** ISO normal, ISO con Z, ISO con >6 digitos fraccion, ISO con timezone offset
### 2. format_iso8601
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `format_iso8601(dt: datetime) -> str`
- **Descripcion:** Formatea datetime a ISO 8601 UTC con milisegundos. Formato: `yyyy-MM-ddTHH:mm:ss.SSSZ`
- **Algoritmo:**
1. Si naive (sin tzinfo): asumir UTC
2. Si aware: convertir a UTC con `astimezone(timezone.utc)`
3. `dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")`
- **Deps:** `datetime`
- **Tests:** datetime naive, datetime con timezone, datetime UTC
### 3. format_simplified
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `format_simplified(dt: datetime, now: datetime) -> str`
- **Descripcion:** Formato humano simplificado: si es del mismo dia muestra `HH:MM:SS`, si no muestra `YYYY-MM-DD`.
- **Algoritmo:**
1. Remover tzinfo para comparacion simple
2. Si `(now - dt).days < 1`: retornar `dt.strftime("%H:%M:%S")`
3. Si no: retornar `dt.strftime("%Y-%m-%d")`
- **Deps:** `datetime`
- **Tests:** mismo dia (formato hora), dia anterior (formato fecha), exactamente 24h
@@ -0,0 +1,43 @@
# Safe Extract ZIP
Fuente conceptual: OpenViking `openviking/utils/zip_safe.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funciones a implementar
### 1. safe_extract_zip
- **Dominio:** infra
- **Lang:** Python
- **Purity:** impure (I/O: escribe archivos)
- **Signature:** `safe_extract_zip(zip_path: str, dest_dir: str) -> None`
- **Descripcion:** Extrae un archivo ZIP con proteccion contra Zip Slip (path traversal attack). Valida que cada archivo extraido quede dentro del directorio destino.
- **Algoritmo:**
1. Resolver dest_dir a path absoluto
2. Normalizar filenames del ZIP (reparar encoding UTF-8)
3. Para cada miembro del ZIP:
- Resolver path completo: `(dest_dir / member.filename).resolve()`
- Verificar que `str(member_path).startswith(str(dest_dir) + os.sep)`
- Si no: raise ValueError "Zip Slip attempt detected"
- Si si: extraer
- **Deps:** `zipfile`, `pathlib`, `os`
- **Error type:** ValueError (zip slip), zipfile.BadZipFile
- **Tests:** ZIP normal, ZIP con path traversal (`../../etc/passwd`), ZIP con paths absolutos
### 2. normalize_zip_filenames
- **Dominio:** infra
- **Lang:** Python
- **Purity:** impure (modifica objeto ZipFile in-place)
- **Signature:** `normalize_zip_filenames(zipf: zipfile.ZipFile) -> None`
- **Descripcion:** Repara nombres de archivos UTF-8 en ZIPs que no tienen el flag UTF-8 seteado. Comun en archivos creados en Windows con nombres CJK.
- **Algoritmo:**
1. Para cada miembro sin flag UTF-8 (bit 0x800):
- Intentar: encode como CP437 → decode como UTF-8
- Si el resultado tiene CJK y el original tiene mojibake: reparar
- Si no: dejar como esta
2. Si se reparo algun nombre: setear `zipf.metadata_encoding = "utf-8"`
- **Deteccion de CJK:** chars en rangos `\u3400-\u4dbf`, `\u4e00-\u9fff`, `\u3000-\u303f`, `\uff00-\uffef`
- **Deteccion de mojibake:** chars en rangos Greek (`\u0370-\u03ff`), Math (`\u2200-\u22ff`), Box Drawing (`\u2500-\u257f`)
- **Deps:** `zipfile`
- **Tests:** ZIP con nombres UTF-8 correctos (no cambiar), ZIP con nombres CJK mojibake (reparar)
@@ -0,0 +1,60 @@
# Envelope Encryption
Fuente conceptual: OpenViking `openviking/crypto/encryptor.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funciones a implementar
### 1. envelope_encrypt
- **Dominio:** cybersecurity
- **Lang:** Python
- **Purity:** impure (genera random bytes)
- **Signature:** `envelope_encrypt(plaintext: bytes, master_key: bytes) -> bytes`
- **Descripcion:** Cifra datos usando patron Envelope Encryption. Genera una clave de archivo aleatoria (file key), cifra los datos con AES-256-GCM usando la file key, luego cifra la file key con la master key.
- **Algoritmo:**
1. Generar file_key aleatorio de 32 bytes
2. Generar data_iv de 12 bytes
3. Cifrar plaintext: `AES-GCM(file_key, data_iv, plaintext)` → encrypted_content
4. Generar key_iv de 12 bytes
5. Cifrar file_key: `AES-GCM(master_key, key_iv, file_key)` → encrypted_file_key
6. Construir envelope: magic + version + lengths + encrypted_file_key + key_iv + data_iv + encrypted_content
- **Formato del envelope:**
```
Magic (4B): b"OVE1" (identificador)
Version (1B): 0x01
Reserved (1B): 0x00
EFK_len (2B): big-endian, longitud de encrypted_file_key
KIV_len (2B): big-endian, longitud de key_iv
DIV_len (2B): big-endian, longitud de data_iv
--- header: 12 bytes total ---
Encrypted File Key (variable)
Key IV (variable)
Data IV (variable)
Encrypted Content (variable, incluye GCM auth tag)
```
- **Deps:** `cryptography` (AESGCM), `secrets`, `struct`
- **Error type:** Exception
- **Tests:** encrypt → decrypt roundtrip, datos vacios, datos grandes
### 2. envelope_decrypt
- **Dominio:** cybersecurity
- **Lang:** Python
- **Purity:** impure (puede fallar en autenticacion)
- **Signature:** `envelope_decrypt(ciphertext: bytes, master_key: bytes) -> bytes`
- **Descripcion:** Descifra datos cifrados con envelope_encrypt.
- **Algoritmo:**
1. Verificar magic bytes `b"OVE1"` al inicio — si no coincide, retornar datos sin cambiar (archivo no cifrado)
2. Parsear header: version, lengths
3. Extraer: encrypted_file_key, key_iv, data_iv, encrypted_content
4. Descifrar file_key: `AES-GCM_decrypt(master_key, key_iv, encrypted_file_key)`
5. Descifrar contenido: `AES-GCM_decrypt(file_key, data_iv, encrypted_content)`
- **Comportamiento especial:** Si ciphertext no empieza con magic, se asume que no esta cifrado y se retorna tal cual. Esto permite usar decrypt en archivos que pueden o no estar cifrados.
- **Deps:** `cryptography`, `struct`
- **Error type:** ValueError (envelope corrupto), AuthenticationError (key incorrecta)
- **Tests:** decrypt de datos cifrados, decrypt de datos no cifrados (passthrough), key incorrecta, envelope truncado, envelope con magic invalido
### 3. build_envelope / parse_envelope (helpers)
Pueden ser funciones internas puras usadas por encrypt/decrypt para construir y parsear el formato binario del envelope.
@@ -0,0 +1,51 @@
# Parse Code AST (multi-language)
Fuente conceptual: OpenViking `openviking/parse/parsers/code/ast/` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funcion a implementar
### parse_code_ast
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure (opera sobre strings)
- **Signature:** `parse_code_ast(source_code: str, language: str) -> list[CodeEntity]`
- **Tipo de retorno:**
```python
@dataclass
class CodeEntity:
kind: str # "function" | "class" | "method" | "interface" | "type" | "struct" | "trait"
name: str
start_line: int
end_line: int
signature: str # firma completa
docstring: str | None
children: list["CodeEntity"] # metodos dentro de clases
```
- **Descripcion:** Extrae entidades de codigo (funciones, clases, metodos, tipos) de codigo fuente usando tree-sitter. Soporta multiples lenguajes.
- **Lenguajes soportados:**
| Lenguaje | tree-sitter grammar | Entidades extraidas |
|----------|--------------------|--------------------|
| Python | tree-sitter-python | function_definition, class_definition, decorated_definition |
| JavaScript/TypeScript | tree-sitter-javascript/typescript | function_declaration, class_declaration, interface_declaration, type_alias_declaration |
| Go | tree-sitter-go | function_declaration, method_declaration, type_declaration |
| Rust | tree-sitter-rust | function_item, struct_item, impl_item, trait_item |
| Java | tree-sitter-java | class_declaration, method_declaration, interface_declaration |
| C++ | tree-sitter-cpp | function_definition, class_specifier, template_declaration |
- **Algoritmo:**
1. Seleccionar grammar de tree-sitter segun `language`
2. Parsear source_code → AST
3. Walk del AST buscando nodos relevantes segun el lenguaje
4. Para cada nodo: extraer nombre, lineas, firma, docstring
5. Para clases: recursion para extraer metodos como children
- **Deps:** `tree-sitter`, `tree-sitter-{language}` (uno por lenguaje)
- **Error type:** ValueError (lenguaje no soportado)
- **Notas:**
- tree-sitter >= 0.23 usa nuevo API con `Language.build_library()`
- Los grammars se instalan como paquetes pip: `pip install tree-sitter-python`
- La firma se extrae del texto del nodo hasta el body
- El docstring se extrae del primer string literal hijo (Python) o comentario previo (otros)
- **Tests:** codigo Python con funciones y clases, Go con funciones y tipos, TypeScript con interfaces, codigo vacio, codigo con errores de sintaxis (tree-sitter es tolerante)
@@ -0,0 +1,48 @@
# Git URL Parser
Fuente conceptual: OpenViking `openviking/utils/code_hosting_utils.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funciones a implementar
### 1. parse_git_url
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `parse_git_url(url: str, known_hosts: list[str] | None = None) -> str | None`
- **Descripcion:** Parsea una URL de code hosting (GitHub, GitLab, etc.) y retorna el path `org/repo`. Soporta HTTP(S), SSH (git@), y git:// protocols.
- **Hosts conocidos por defecto:** `["github.com", "gitlab.com"]`
- **Algoritmo:**
1. Si empieza con `git@`: extraer host:path, split por `:`, tomar primeros 2 segmentos, remover `.git`
2. Si empieza con `http://`, `https://`, `git://`, `ssh://`: parsear con urlparse, verificar netloc en hosts, tomar primeros 2 segmentos del path
3. Sanitizar org y repo: solo `[a-zA-Z0-9_-]`
4. Retornar `org/repo` o None si no es valida
- **Deps:** `urllib.parse`
- **Tests:** URL HTTPS GitHub, URL SSH git@, URL con .git suffix, URL con path extra (issues/123), URL no reconocida
### 2. is_git_repo_url
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `is_git_repo_url(url: str, known_hosts: list[str] | None = None) -> bool`
- **Descripcion:** Verifica estrictamente si una URL apunta a un repositorio git clonable (no a un issue, PR, file, etc.).
- **Algoritmo:**
1. `git@`/`ssh://`/`git://`: si el dominio es conocido → True
2. `http://`/`https://`: dominio conocido Y exactamente 2 segmentos de path (org/repo) → True
3. Tambien acepta: org/repo/tree/<ref> (branch/commit)
4. No acepta: org/repo/issues/123, org/repo/blob/main/file.py
- **Deps:** `urllib.parse`
- **Tests:** URL repo valida, URL de issue (False), URL de blob/file (False), URL con tree/branch (True)
### 3. validate_git_ssh_uri
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `validate_git_ssh_uri(url: str) -> None`
- **Descripcion:** Valida formato de URI SSH de git (`git@host:path`). Raise ValueError si invalida.
- **Algoritmo:** Verificar que empieza con `git@`, contiene `:`, y tiene path no vacio despues del `:`
- **Deps:** ninguna
- **Tests:** URI valida, URI sin git@, URI sin colon, URI con path vacio
@@ -0,0 +1,26 @@
# Calculate Media Strategy
Fuente conceptual: OpenViking `openviking/parse/base.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funcion a implementar
### calculate_media_strategy
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `calculate_media_strategy(image_count: int, line_count: int) -> str`
- **Retorno:** `"full_page_vlm"` | `"extract"` | `"text_only"`
- **Descripcion:** Determina la estrategia optima de procesamiento de medios para un documento basado en la proporcion de imagenes vs texto.
- **Algoritmo:**
1. Si `line_count > 0` y (`image_count / line_count > 0.3` o `image_count >= 5`): → `"full_page_vlm"` (documento dominado por imagenes, usar vision-language model)
2. Si `image_count > 0`: → `"extract"` (pocas imagenes, extraer y procesar individualmente)
3. Si `image_count == 0`: → `"text_only"` (solo texto)
- **Deps:** ninguna
- **Tests:**
- 0 imagenes → text_only
- 2 imagenes, 100 lineas → extract (ratio bajo)
- 10 imagenes, 20 lineas → full_page_vlm (ratio > 0.3)
- 5 imagenes, 100 lineas → full_page_vlm (>= 5 imagenes)
- 0 lineas, cualquier imagenes → text_only (division por cero evitada)
@@ -0,0 +1,53 @@
# Parser Registry (patron extensible)
Fuente conceptual: OpenViking `openviking/parse/registry.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Clase a implementar
### ParserRegistry
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (mantiene estado mutable, singleton)
- **Descripcion:** Registry extensible que despacha parsing de archivos al parser correcto basado en extension. Patron plugin: registrar parsers por nombre y extensiones, resolver automaticamente.
- **Signature:**
```python
class ParserRegistry:
def __init__(self): ...
def register(self, name: str, parser: BaseParser) -> None: ...
def unregister(self, name: str) -> None: ...
def get_parser(self, name: str) -> BaseParser | None: ...
def get_parser_for_file(self, path: str) -> BaseParser | None: ...
async def parse(self, source: str, **kwargs) -> ParseResult: ...
def list_parsers(self) -> list[str]: ...
def list_supported_extensions(self) -> list[str]: ...
```
- **Protocolo BaseParser:**
```python
class BaseParser(ABC):
@property
@abstractmethod
def supported_extensions(self) -> list[str]: ...
@abstractmethod
async def parse(self, source: str | Path, **kwargs) -> ParseResult: ...
@abstractmethod
async def parse_content(self, content: str, source_path: str | None = None, **kwargs) -> ParseResult: ...
def can_parse(self, path: str) -> bool:
return Path(path).suffix.lower() in self.supported_extensions
```
- **Algoritmo de despacho (`parse()`):**
1. Si source es URL de repo de codigo → delegar a code parser
2. Si source parece path de archivo (corto, sin newlines) y existe:
- Si es directorio → delegar a directory parser
- Resolver parser por extension → delegar
- Si no hay parser → fallback a text parser
3. Si no es path → parsear como string de contenido con text parser
- **Extension map:** `{".md": "markdown", ".pdf": "pdf", ".html": "html", ...}` construido automaticamente al registrar parsers
- **Singleton:** `get_registry()` retorna instancia global lazy-initialized
- **Deps:** `pathlib`
- **Tests:** registrar parser custom, resolver por extension, parse file, parse string, unregister, extension desconocida (fallback a text)
- **Notas:** Este patron es util mas alla de parsing — cualquier sistema de plugins extensible por tipo de archivo puede usar este patron.
@@ -0,0 +1,22 @@
# Read File with Encoding Detection
Fuente conceptual: OpenViking `openviking/parse/parsers/base_parser.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funcion a implementar
### read_file_with_encoding
- **Dominio:** infra
- **Lang:** Python
- **Purity:** impure (I/O: lee archivo)
- **Signature:** `read_file_with_encoding(path: str, encodings: list[str] | None = None) -> str`
- **Descripcion:** Lee un archivo de texto intentando multiples encodings en orden hasta encontrar uno que funcione. Util para archivos de origen desconocido (Windows, Latin-1, etc.).
- **Algoritmo:**
1. Encodings por defecto: `["utf-8", "utf-8-sig", "latin-1", "cp1252"]`
2. Para cada encoding: intentar abrir y leer
3. Si UnicodeDecodeError: intentar siguiente
4. Si ninguno funciona: raise ValueError
- **Deps:** ninguna (stdlib)
- **Error type:** ValueError ("Unable to decode file")
- **Tests:** archivo UTF-8, archivo UTF-8 con BOM, archivo Latin-1, archivo binario (falla)
@@ -0,0 +1,77 @@
# Tipo: ParseResult / ResourceNode / NodeType
Fuente conceptual: OpenViking `openviking/parse/base.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Tipos a implementar
### 1. NodeType (enum)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** sum
- **Definicion:**
```python
class NodeType(Enum):
ROOT = "root"
SECTION = "section"
```
- **Descripcion:** Tipos de nodo en un arbol de documento parseado. Solo ROOT y SECTION — todo el contenido detallado (parrafos, code blocks, tablas, listas) permanece como markdown dentro del campo content del nodo.
- **Notas:** Diseno intencionalmente simple. Evita decomposicion fina de nodos (no hay PARAGRAPH, CODE_BLOCK, TABLE, etc.) — el contenido se preserva en formato markdown.
### 2. ResourceNode (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class ResourceNode:
type: NodeType
title: str | None = None
level: int = 0 # 0=root, 1=top section, etc.
content: str = "" # markdown content de este nodo
children: list["ResourceNode"] = field(default_factory=list)
meta: dict[str, Any] = field(default_factory=dict)
```
- **Metodos:**
- `add_child(child: ResourceNode) -> None`
- `get_text(include_children: bool = True) -> str` — contenido concatenado recursivo
- `get_abstract(max_length: int = 256) -> str` — resumen corto (title o primeros N chars)
- `get_overview(max_length: int = 4000) -> str` — overview con lista de children
- `to_dict() -> dict` — serializacion recursiva
- `from_dict(data: dict) -> ResourceNode` — deserializacion (classmethod)
- **Descripcion:** Nodo en un arbol jerarquico de documento. Preserva la estructura natural del documento (secciones, subsecciones) sin perder contenido. Cada nodo puede tener hijos (subsecciones) y metadata.
- **Uso:** Resultado intermedio de parsing. Los parsers (markdown, pdf, html, docx) producen arboles de ResourceNode.
### 3. ParseResult (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class ParseResult:
root: ResourceNode
source_path: str | None = None
source_format: str | None = None # "pdf", "markdown", "html", "docx", etc.
parser_name: str | None = None # "PDFParser", "MarkdownParser", etc.
parser_version: str | None = None
parse_time: float | None = None # segundos
parse_timestamp: datetime | None = None
meta: dict[str, Any] = field(default_factory=dict)
warnings: list[str] = field(default_factory=list)
```
- **Metodos:**
- `success -> bool` (property) — True si no hay warnings
- `get_all_nodes() -> list[ResourceNode]` — flatten recursivo del arbol
- `get_sections(min_level=0, max_level=10) -> list[ResourceNode]` — filtrar secciones por nivel
- **Descripcion:** Resultado completo de parsear un documento. Contiene el arbol de nodos, metadata del proceso, y warnings. Es el tipo de retorno unificado de todos los parsers.
- **Helper:**
```python
def create_parse_result(root, source_path=None, source_format=None,
parser_name=None, parse_time=None, meta=None,
warnings=None) -> ParseResult
```
@@ -0,0 +1,41 @@
# Tipo: ClassifiedFile / DirectoryScanResult
Fuente conceptual: OpenViking `openviking/parse/directory_scan.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Tipos a implementar
### 1. ClassifiedFile (product)
- **Dominio:** infra
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class ClassifiedFile:
path: str # path absoluto
rel_path: str # relativo a root, siempre forward slashes
classification: str # "processable" | "unsupported"
```
- **Descripcion:** Un archivo con su clasificacion tras un scan de directorio. `rel_path` siempre usa forward slashes para consistencia cross-platform.
### 2. DirectoryScanResult (product)
- **Dominio:** infra
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class DirectoryScanResult:
root: str
processable: list[ClassifiedFile] = field(default_factory=list)
unsupported: list[ClassifiedFile] = field(default_factory=list)
skipped: list[str] = field(default_factory=list) # "path (reason)"
warnings: list[str] = field(default_factory=list)
```
- **Metodos:**
- `all_processable_files() -> list[ClassifiedFile]` — alias de processable, para API clara
- **Descripcion:** Resultado de un pre-scan de directorio. Clasifica todos los archivos en procesables vs no soportados, y reporta archivos/dirs que se skipearon (dot files, symlinks, dirs ignorados) con la razon del skip.
- **Uso:** Validacion previa antes de importar un directorio completo. Permite reportar archivos no soportados al usuario antes de procesar.
@@ -0,0 +1,40 @@
# Tipo: CodeEntity
Fuente conceptual: OpenViking `openviking/parse/parsers/code/ast/` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Tipo a implementar
### CodeEntity (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class CodeEntity:
kind: str # "function" | "class" | "method" | "interface" | "type" | "struct" | "trait"
name: str # nombre de la entidad
start_line: int # linea de inicio (1-based)
end_line: int # linea de fin (1-based)
signature: str # firma completa (hasta el body)
docstring: str | None # docstring/comment extraido
language: str # "python" | "go" | "typescript" | "rust" | "java" | "cpp"
children: list["CodeEntity"] = field(default_factory=list) # metodos dentro de clases
```
- **Metodos:**
- `line_count -> int` (property) — `end_line - start_line + 1`
- `has_docstring -> bool` (property)
- `to_dict() -> dict` — serializacion recursiva
- `from_dict(data: dict) -> CodeEntity` — deserializacion (classmethod)
- **Descripcion:** Entidad de codigo extraida por analisis AST (tree-sitter). Representa una funcion, clase, metodo, tipo, interfaz, struct o trait encontrado en codigo fuente. Las clases contienen sus metodos como children.
- **Uso:** Resultado de `parse_code_ast()`. Util para:
- Indexar codigo en un registry
- Generar skeletons/resumen de archivos de codigo
- Extraer funciones candidatas de repos externos
- Navegacion de codigo (jump to definition)
- **Notas:**
- `signature` incluye decoradores/annotations en Python, generic params en TypeScript/Go, visibility modifiers en Java/Rust
- `docstring` es el primer string literal hijo en Python, el comentario `///` o `/** */` previo en otros lenguajes
- `children` solo se usa para metodos dentro de clases/structs/traits/interfaces
@@ -0,0 +1,96 @@
# Tipo: Message / Part (multipart messages)
Fuente conceptual: OpenViking `openviking/message/` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Tipos a implementar
### 1. TextPart (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class TextPart:
text: str = ""
type: str = "text" # literal discriminator
```
### 2. ContextPart (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class ContextPart:
uri: str = "" # referencia al contexto
context_type: str = "memory" # "memory" | "resource" | "skill"
abstract: str = "" # resumen del contexto
type: str = "context" # literal discriminator
```
### 3. ToolPart (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class ToolPart:
tool_id: str = ""
tool_name: str = ""
tool_input: dict | None = None
tool_output: str = ""
tool_status: str = "pending" # "pending" | "running" | "completed" | "error"
duration_ms: float | None = None
prompt_tokens: int | None = None
completion_tokens: int | None = None
type: str = "tool" # literal discriminator
```
### 4. Part (sum type)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** sum
- **Definicion:**
```python
Part = Union[TextPart, ContextPart, ToolPart]
def part_from_dict(data: dict) -> Part:
"""Deserializa un Part desde dict, usando el campo 'type' como discriminador."""
```
### 5. Message (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class Message:
id: str
role: str # "user" | "assistant"
parts: list[Part]
created_at: datetime | None = None
```
- **Metodos:**
- `content -> str` (property) — texto del primer TextPart
- `estimated_tokens -> int` (property) — estimacion ceil(total_chars / 4)
- `to_dict() -> dict` — serializacion a JSONL-compatible dict
- `from_dict(data: dict) -> Message` (classmethod) — deserializacion
- `create_user(content: str) -> Message` (classmethod) — factory para mensajes de usuario
- `create_assistant(content: str, context_refs=None, tool_calls=None) -> Message` (classmethod)
- `get_context_parts() -> list[ContextPart]`
- `get_tool_parts() -> list[ToolPart]`
- `find_tool_part(tool_id: str) -> ToolPart | None`
- `to_jsonl() -> str` — serializacion a string JSON
- **Descripcion:** Modelo de mensaje multipart para conversaciones con agentes. Soporta texto, referencias a contexto (memoria/recurso/skill) y llamadas a herramientas, todo en un mensaje unificado. Diseñado para ser serializable a JSONL para persistencia.
- **Estimacion de tokens:** Cuenta chars de text parts, abstracts de context parts, y tool inputs/outputs. Divide entre 4 (heuristica). Util para gestionar budgets de tokens en contexto de LLMs.
- **Uso:** Historial de conversacion, sesiones de agentes, logs de interaccion.
@@ -0,0 +1,144 @@
# Tipos: Retrieval (TypedQuery, MatchedContext, QueryResult, FindResult)
Fuente conceptual: OpenViking `openviking_cli/retrieve/types.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Tipos a implementar
### 1. ContextType (enum/sum)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** sum
- **Definicion:**
```python
class ContextType(str, Enum):
MEMORY = "memory"
RESOURCE = "resource"
SKILL = "skill"
```
- **Descripcion:** Tipo de contexto para busqueda/retrieval. Permite filtrar resultados por categoria.
### 2. TypedQuery (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class TypedQuery:
query: str # texto de la query
context_type: ContextType | None # tipo de contexto objetivo
intent: str # descripcion de la intencion
priority: int = 3 # 1-5 (1 es mayor prioridad)
target_directories: list[str] = field(default_factory=list) # URIs de directorios objetivo
```
- **Descripcion:** Query tipada que resulta del analisis de intenciones. Un plan de busqueda se descompone en multiples TypedQueries, cada una apuntando a un tipo de contexto diferente.
### 3. QueryPlan (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class QueryPlan:
queries: list[TypedQuery]
session_context: str # resumen del contexto de sesion
reasoning: str # razonamiento del LLM
```
- **Descripcion:** Plan de busqueda generado por analisis de intenciones (LLM). Contiene multiples queries tipadas y el razonamiento detras de la descomposicion.
### 4. RelatedContext (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class RelatedContext:
uri: str
abstract: str
```
### 5. MatchedContext (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class MatchedContext:
uri: str
context_type: ContextType
level: int = 2 # 0=abstract, 1=overview, 2=detail
abstract: str = ""
overview: str | None = None
category: str = ""
score: float = 0.0 # 0-1 relevancia
match_reason: str = ""
relations: list[RelatedContext] = field(default_factory=list)
```
- **Descripcion:** Contexto encontrado por el retriever con su score de relevancia, nivel de detalle, y contextos relacionados.
### 6. ScoreDistribution (product)
- **Dominio:** datascience
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class ScoreDistribution:
scores: list[tuple[str, float]] # [(uri, score), ...] ordenados desc
min_score: float = 0.0
max_score: float = 0.0
mean_score: float = 0.0
threshold: float = 0.0
```
- **Metodos:**
- `from_scores(uri_scores, threshold=0.0) -> ScoreDistribution` (classmethod) — calcula stats
- `to_dict() -> dict` — incluye count y above_threshold
- **Descripcion:** Estadisticas de distribucion de scores para visualizacion y debugging de retrieval.
### 7. QueryResult (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class QueryResult:
query: TypedQuery
matched_contexts: list[MatchedContext]
searched_directories: list[str]
```
- **Descripcion:** Resultado de una sola TypedQuery con los contextos encontrados y los directorios que se buscaron.
### 8. FindResult (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class FindResult:
memories: list[MatchedContext]
resources: list[MatchedContext]
skills: list[MatchedContext]
query_plan: QueryPlan | None = None
query_results: list[QueryResult] | None = None
total: int = 0 # auto-calculado en __post_init__
```
- **Metodos:**
- `__iter__` — itera sobre todos los MatchedContext (memories + resources + skills)
- `__post_init__` — calcula total
- `to_dict(include_provenance=False) -> dict` — serializacion
- `from_dict(data: dict) -> FindResult` (classmethod) — deserializacion
- **Descripcion:** Resultado final de una busqueda completa, agrupado por tipo de contexto. Opcionalmente incluye provenance (traza de busqueda) para observabilidad.
@@ -0,0 +1,115 @@
# Tipos: Memory System (MemoryField, MemoryTypeSchema, MemoryData)
Fuente conceptual: OpenViking `openviking/session/memory/dataclass.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Tipos a implementar
### 1. FieldType (enum/sum)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** sum
- **Definicion:**
```python
class FieldType(str, Enum):
STRING = "string"
INTEGER = "integer"
FLOAT = "float"
BOOLEAN = "boolean"
LIST = "list"
DICT = "dict"
DATETIME = "datetime"
```
### 2. MergeOp (enum/sum)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** sum
- **Definicion:**
```python
class MergeOp(str, Enum):
PATCH = "patch" # SEARCH/REPLACE para strings, replace directo para otros
SUM = "sum" # suma numerica (contadores)
IMMUTABLE = "immutable" # no se puede modificar despues de crear
```
- **Descripcion:** Estrategia de merge cuando se actualiza un campo de memoria existente. PATCH aplica cambios parciales (search/replace en strings), SUM acumula valores numericos, IMMUTABLE protege contra modificacion.
### 3. MemoryField (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class MemoryField:
name: str # nombre del campo
field_type: FieldType # tipo de dato
description: str = "" # descripcion para el LLM
merge_op: MergeOp = MergeOp.PATCH # estrategia de merge
```
- **Descripcion:** Definicion de un campo dentro de un tipo de memoria. El merge_op determina como se actualizan valores existentes.
### 4. MemoryTypeSchema (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class MemoryTypeSchema:
memory_type: str # nombre del tipo (ej: "profile", "preferences")
description: str = "" # descripcion del tipo
fields: list[MemoryField] = field(default_factory=list)
filename_template: str = "" # template para nombre de archivo
directory: str = "" # directorio donde se almacenan
enabled: bool = True
operation_mode: str = "upsert" # "upsert" | "add_only" | "update_only"
```
- **Descripcion:** Schema de un tipo de memoria. Define la estructura de los datos, la estrategia de merge por campo, y como se organizan en el filesystem. Soporta 3 modos de operacion:
- `upsert`: crear o actualizar (default)
- `add_only`: solo crear, nunca actualizar existentes
- `update_only`: solo actualizar existentes, nunca crear nuevos
### 5. MemoryData (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class MemoryData:
memory_type: str # tipo de memoria
uri: str | None = None # URI para updates
fields: dict[str, Any] = field(default_factory=dict) # datos dinamicos
abstract: str | None = None # L0 resumen corto
overview: str | None = None # L1 resumen medio
content: str | None = None # L2 contenido completo
name: str | None = None
tags: list[str] = field(default_factory=list)
created_at: datetime | None = None
updated_at: datetime | None = None
```
- **Metodos:**
- `get_field(field_name: str) -> Any`
- `set_field(field_name: str, value: Any) -> None`
- **Descripcion:** Instancia concreta de un dato de memoria. Los campos son dinamicos (dict) y se validan contra el MemoryTypeSchema correspondiente. Soporta 3 niveles de contenido (L0 abstract, L1 overview, L2 detail) para retrieval jerarquico.
### 6. Categorias de memoria predefinidas
Categorias estandar que un sistema de memoria de agente deberia soportar:
| Categoria | Descripcion | Ejemplo |
|-----------|-------------|---------|
| profile | Informacion del usuario | nombre, rol, expertise |
| preferences | Preferencias | idioma, estilo de respuesta |
| entities | Entidades conocidas | personas, proyectos, tools |
| events | Eventos ocurridos | reuniones, deployments, incidentes |
| cases | Casos/precedentes | bugs resueltos, decisiones |
| patterns | Patrones observados | workflows, preferencias recurrentes |
| tools | Herramientas conocidas | comandos, APIs, shortcuts |
| skills | Habilidades/capacidades | del agente o del usuario |
@@ -0,0 +1,69 @@
# Tipos: Context (modelo unificado de contexto)
Fuente conceptual: OpenViking `openviking/core/context.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Tipos a implementar
### 1. ResourceContentType (enum/sum)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** sum
- **Definicion:**
```python
class ResourceContentType(str, Enum):
TEXT = "text"
IMAGE = "image"
VIDEO = "video"
AUDIO = "audio"
BINARY = "binary"
```
### 2. ContextLevel (enum/sum)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** sum
- **Definicion:**
```python
class ContextLevel(int, Enum):
ABSTRACT = 0 # L0: resumen corto (~256 chars)
OVERVIEW = 1 # L1: overview (~4000 chars)
DETAIL = 2 # L2: contenido completo
```
- **Descripcion:** Nivel de detalle de un contexto para indexacion vectorial y retrieval jerarquico. El retriever primero busca en L0 (rapido, barato), luego refina con L1/L2.
### 3. Context (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class Context:
id: str # UUID
uri: str # identificador unico del recurso
parent_uri: str | None = None # URI del padre (directorio)
is_leaf: bool = False # True si es nodo hoja (archivo)
abstract: str = "" # L0 resumen
context_type: str = "resource" # "memory" | "resource" | "skill"
category: str = "" # subcategoria
level: int | None = None # ContextLevel
created_at: datetime | None = None
updated_at: datetime | None = None
active_count: int = 0 # veces accedido (para hotness)
related_uri: list[str] = field(default_factory=list) # URIs relacionados
meta: dict[str, Any] = field(default_factory=dict)
```
- **Metodos:**
- `to_dict() -> dict` — serializacion
- `from_dict(data: dict) -> Context` (classmethod) — deserializacion
- **Descripcion:** Modelo unificado de contexto que representa cualquier recurso (documento, memoria, skill) indexable y buscable. Combina metadata, jerarquia (parent/children via URI), conteo de accesos (para hotness scoring), y relaciones.
- **Uso:** Almacenado en vector DB con su embedding. El retriever busca por similitud vectorial y luego usa `active_count` + `updated_at` para hotness scoring.
- **Notas:**
- `uri` sigue un esquema tipo filesystem: `viking://resources/docs/readme.md`
- `parent_uri` permite reconstruir la jerarquia de directorios
- `active_count` se incrementa cada vez que el contexto se retrieva — usado por `hotness_score()`
- `related_uri` permite grafos de relaciones entre contextos
@@ -0,0 +1,86 @@
# Validacion y Schemas
Gap identificado en el registry. Reimplementar desde cero.
No hay funciones de validacion de datos estructurados. Necesarias para el pipeline
de ingesta (validar documentos parseados) y para assertions mas ricas en operations.db.
## Funciones a implementar
### validate_json_schema
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `validate_json_schema(data: dict, schema: dict) -> tuple[bool, list[str]]`
- **Retorno:** (valid, errors) — True y lista vacia si cumple, False y lista de errores si no
- **Descripcion:** Valida un dict contra un schema JSON Schema (draft 2020-12) sin dependencias externas. Soporta types, required, properties, items, minimum/maximum, minLength/maxLength, pattern, enum. No pretende cubrir todo el spec — solo lo practico.
- **Algoritmo:**
1. Validar type (string, number, integer, boolean, array, object, null)
2. Para object: validar required, iterar properties recursivamente
3. Para array: validar items recursivamente, minItems/maxItems
4. Para string: pattern (re.match), minLength/maxLength, enum
5. Para number/integer: minimum/maximum, exclusiveMinimum/exclusiveMaximum
6. Acumular errores con path (ej: "$.address.zip: expected string, got int")
- **Deps:** `re` (solo stdlib)
- **Tests:**
- Schema simple con types y required
- Nested object validation
- Array con items schema
- Pattern validation en strings
- Numeric ranges
- Multiples errores acumulados con paths correctos
- Schema vacio (acepta todo)
- Data None contra required field
### validate_struct_fields
- **Dominio:** core
- **Lang:** Go
- **Purity:** pure
- **Signature:** `ValidateStructFields(data map[string]any, rules map[string]string) (bool, []string)`
- **Retorno:** (valid, errors)
- **Descripcion:** Valida campos de un map contra reglas declarativas tipo `"required,min=1,max=100,type=string"`. Pensado para validar metadata de entities en operations.db o resultados de queries sin definir structs Go.
- **Reglas soportadas:**
- `required` — campo debe existir y no ser nil/""
- `type=string|int|float|bool` — validar tipo Go subyacente
- `min=N`, `max=N` — para numericos
- `minlen=N`, `maxlen=N` — para strings
- `oneof=a|b|c` — valor debe ser uno de los listados
- `pattern=regex` — para strings
- **Deps:** `regexp`, `strconv`, `strings` (solo stdlib)
- **Tests:**
- Campo required presente y ausente
- Type validation (string como int falla)
- Numeric ranges
- String lengths
- Oneof validation
- Pattern matching
- Multiples reglas combinadas
- Map vacio con reglas required
### coerce_types
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `coerce_types(data: dict, schema: dict[str, str]) -> tuple[dict, list[str]]`
- **Retorno:** (coerced_data, warnings) — nuevo dict con tipos corregidos, warnings para coerciones lossy
- **Descripcion:** Convierte valores de un dict a los tipos esperados. Schema es `{"field": "int|float|str|bool|datetime|list[str]"}`. Util para normalizar datos que vienen como strings de CSV, JSON, o query params antes de insertarlos en operations.db.
- **Coerciones:**
- str → int: `int(v)`, warning si tiene decimales
- str → float: `float(v)`
- str → bool: "true/1/yes" → True, "false/0/no" → False
- str → datetime: ISO 8601 parse
- str → list[str]: split por "," y strip
- Valor ya del tipo correcto → pass through
- Coercion imposible → mantener original + warning
- **Deps:** `datetime` (solo stdlib)
- **Tests:**
- String "42" → int 42
- String "3.14" → float 3.14
- String "true" → bool True
- String "2024-01-15T10:30:00Z" → datetime
- Coercion fallida genera warning sin crash
- Dict con mix de tipos ya correctos y strings
- Campo ausente en schema → pass through sin tocar
@@ -0,0 +1,122 @@
# Transformacion de datos tabulares
Gap identificado en el registry. Reimplementar desde cero.
Hay estadistica y rolling windows pero no transformaciones tabulares puras.
Necesarias para pipelines Go/Python que procesan CSVs o resultados de queries
sin depender de pandas.
Datos tabulares = `list[dict]` en Python, `[]map[string]any` en Go.
## Funciones a implementar
### pivot
- **Dominio:** datascience
- **Lang:** Python
- **Purity:** pure
- **Signature:** `pivot(rows: list[dict], index: str, columns: str, values: str, agg: str = "sum") -> list[dict]`
- **Retorno:** Lista de dicts donde cada dict tiene el campo index + un campo por cada valor unico de columns
- **Descripcion:** Pivot table sin pandas. Agrupa por `index`, expande valores unicos de `columns` como nuevas columnas, agrega `values` con la funcion indicada.
- **Agregaciones:** sum, count, mean, min, max, first, last
- **Ejemplo:**
```python
rows = [
{"region": "US", "product": "A", "sales": 10},
{"region": "US", "product": "B", "sales": 20},
{"region": "EU", "product": "A", "sales": 15},
]
pivot(rows, index="region", columns="product", values="sales")
# [{"region": "US", "A": 10, "B": 20}, {"region": "EU", "A": 15, "B": 0}]
```
- **Deps:** ninguna (solo stdlib)
- **Tests:**
- Pivot basico con sum
- Pivot con count y mean
- Valores faltantes rellenados con 0 (numericos) o None (strings)
- Una sola fila
- Multiples valores por celda (requiere agregacion)
### melt
- **Dominio:** datascience
- **Lang:** Python
- **Purity:** pure
- **Signature:** `melt(rows: list[dict], id_vars: list[str], value_vars: list[str], var_name: str = "variable", value_name: str = "value") -> list[dict]`
- **Retorno:** Lista de dicts en formato largo (unpivoted)
- **Descripcion:** Inversa de pivot — convierte columnas en filas. Cada combinacion de id_vars + value_var genera una fila.
- **Ejemplo:**
```python
rows = [{"region": "US", "q1": 10, "q2": 20}]
melt(rows, id_vars=["region"], value_vars=["q1", "q2"])
# [{"region": "US", "variable": "q1", "value": 10},
# {"region": "US", "variable": "q2", "value": 20}]
```
- **Deps:** ninguna
- **Tests:**
- Melt basico
- Multiples id_vars
- value_vars con None → melt todas las columnas no-id
- Fila con campo faltante en value_vars
### join_by_key
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `join_by_key(left: list[dict], right: list[dict], key: str, how: str = "inner") -> list[dict]`
- **Retorno:** Lista de dicts con campos de ambos lados mergeados
- **Descripcion:** Join de dos tablas por una clave comun. Soporta inner, left, right, outer. Campos duplicados del right se sufijan con `_right`.
- **Algoritmo:**
1. Indexar right por key en un dict (O(n))
2. Iterar left, buscar match en index (O(m))
3. Merge de campos segun tipo de join
- **Deps:** ninguna
- **Tests:**
- Inner join (solo matches)
- Left join (todos los left, None para right sin match)
- Right join
- Outer join
- Campos duplicados con sufijo
- Key ausente en alguna fila
### aggregate_by_group
- **Dominio:** datascience
- **Lang:** Python
- **Purity:** pure
- **Signature:** `aggregate_by_group(rows: list[dict], group_by: list[str], aggs: dict[str, str]) -> list[dict]`
- **Retorno:** Lista de dicts agrupados con valores agregados
- **Descripcion:** GROUP BY + agregaciones sobre datos tabulares. `aggs` es `{"column": "sum|mean|count|min|max|first|last|collect"}`. `collect` acumula valores en lista.
- **Ejemplo:**
```python
rows = [{"dept": "eng", "salary": 100}, {"dept": "eng", "salary": 120}, {"dept": "sales", "salary": 80}]
aggregate_by_group(rows, group_by=["dept"], aggs={"salary": "mean"})
# [{"dept": "eng", "salary": 110.0}, {"dept": "sales", "salary": 80.0}]
```
- **Deps:** ninguna
- **Tests:**
- Group by una columna con sum
- Group by multiples columnas
- Agregacion mean, count, min, max
- collect acumula en lista
- Grupo con una sola fila
- Campo con None (ignorar en agregaciones numericas)
### pivot (Go)
- **Dominio:** datascience
- **Lang:** Go
- **Purity:** pure
- **Signature:** `Pivot(rows []map[string]any, index, columns, values, agg string) []map[string]any`
- **Descripcion:** Misma semantica que la version Python. Go no tiene generics suficientes para hacerlo elegante, pero con `map[string]any` y type assertions funciona para datos deserializados de JSON/SQL.
- **Tests:** Mismos casos que Python
### join_by_key (Go)
- **Dominio:** core
- **Lang:** Go
- **Purity:** pure
- **Signature:** `JoinByKey(left, right []map[string]any, key, how string) []map[string]any`
- **Descripcion:** Misma semantica que la version Python.
- **Tests:** Mismos casos que Python
@@ -0,0 +1,118 @@
# Serializacion y formato de salida
Gap identificado en el registry. Reimplementar desde cero.
Los pipelines terminan en operations.db pero no hay funciones para exportar
datos a formatos consumibles (CSV, JSONL, HTML, templates).
## Funciones a implementar
### to_csv
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `to_csv(rows: list[dict], columns: list[str] | None = None, delimiter: str = ",", include_header: bool = True) -> str`
- **Retorno:** String CSV completo
- **Descripcion:** Serializa datos tabulares a CSV. Si columns es None, usa las keys de la primera fila. Escapa campos con comillas, newlines y delimiters correctamente (RFC 4180).
- **Deps:** ninguna (no usar csv module para mantener control total)
- **Tests:**
- Lista simple → CSV con header
- Campos con comas, comillas, newlines (escaping correcto)
- columns explicitas (reordena y filtra)
- include_header=False
- Lista vacia → string vacio
- Valores None → campo vacio
### from_csv
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `from_csv(text: str, delimiter: str = ",", has_header: bool = True) -> list[dict]`
- **Retorno:** Lista de dicts con keys del header
- **Descripcion:** Parser CSV a datos tabulares. Complemento de to_csv. Si has_header=False, genera keys col_0, col_1, etc.
- **Deps:** ninguna
- **Tests:**
- CSV simple con header
- Campos con escaping (comillas, comas internas)
- Sin header (keys generadas)
- Lineas vacias ignoradas
- Un solo campo por fila
### to_jsonl
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `to_jsonl(rows: list[dict]) -> str`
- **Retorno:** String con un JSON por linea
- **Descripcion:** Serializa a JSON Lines (newline-delimited JSON). Cada dict se serializa como una linea JSON independiente. Util para streaming, logging estructurado, y formatos de intercambio.
- **Deps:** `json` (stdlib)
- **Tests:**
- Lista de dicts → JSONL
- Valores con unicode, None, nested dicts
- Lista vacia → string vacio
- Cada linea es JSON parseable independientemente
### from_jsonl
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `from_jsonl(text: str) -> list[dict]`
- **Retorno:** Lista de dicts
- **Descripcion:** Parser JSONL a lista de dicts. Ignora lineas vacias. Complemento de to_jsonl.
- **Deps:** `json`
- **Tests:**
- JSONL valido
- Lineas vacias intercaladas
- Linea con JSON invalido → raise con numero de linea
### render_template
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `render_template(template: str, context: dict, missing: str = "") -> str`
- **Retorno:** String con placeholders reemplazados
- **Descripcion:** Motor de templates minimalista sin dependencias. Soporta `{{variable}}`, `{{obj.field}}` (dot access en dicts anidados), `{% for item in list %}...{% endfor %}`, `{% if cond %}...{% endif %}`. No pretende reemplazar Jinja — cubre el 80% de casos simples.
- **Sintaxis:**
- `{{var}}` — sustitucion simple, HTML-escaped por defecto
- `{{{var}}}` — sustitucion sin escape (raw)
- `{{obj.key.subkey}}` — dot-path traversal en dicts
- `{% for x in items %}{{x}}{% endfor %}` — iteracion
- `{% if var %}...{% endif %}` — condicional (truthy check)
- `{% if not var %}...{% endif %}` — negacion
- **Deps:** `re` (solo stdlib)
- **Tests:**
- Sustitucion simple
- Dot-path en nested dicts
- For loop con lista de strings
- For loop con lista de dicts
- If/endif condicional
- Variable missing → string vacio (configurable)
- HTML escaping por defecto
- Triple braces sin escape
### generate_html_report
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `generate_html_report(title: str, sections: list[dict]) -> str`
- **Retorno:** String HTML completo (con DOCTYPE, head, body)
- **Descripcion:** Genera un reporte HTML autocontenido con CSS inline. Cada section es `{"heading": str, "type": "table"|"text"|"kpi"|"list", "data": ...}`. Para exportar resultados de pipelines o assertions a algo compartible sin servidor.
- **Tipos de seccion:**
- `table`: data es `list[dict]` → renderiza <table> con headers
- `text`: data es `str` → renderiza <p> con markdown basico (bold, links)
- `kpi`: data es `list[{"label": str, "value": str|number, "delta": str|None}]` → cards
- `list`: data es `list[str]` → renderiza <ul>
- **CSS:** Inline en <style>, tema minimalista con max-width, font-family sans-serif, tabla con zebra stripes
- **Deps:** ninguna
- **Tests:**
- Reporte con una tabla
- Reporte con multiples secciones mixtas
- KPI con deltas positivos y negativos
- Caracteres especiales HTML escapados en data
- Titulo con caracteres especiales
@@ -0,0 +1,96 @@
# HTTP Client primitivas
Gap identificado en el registry. Reimplementar desde cero.
Hay fetch_ohlcv y stream_ticks pero no un HTTP client generico reutilizable.
Necesario para integraciones con APIs externas sin reimplementar retry/headers cada vez.
## Funciones a implementar
### http_get_json
- **Dominio:** infra
- **Lang:** Python
- **Purity:** impure (I/O de red)
- **Signature:** `http_get_json(url: str, headers: dict[str, str] | None = None, params: dict[str, str] | None = None, timeout: float = 30.0) -> dict`
- **Retorno:** Response body parseado como dict/list
- **Descripcion:** GET request que espera JSON. Agrega `Accept: application/json` automaticamente. Lanza error descriptivo si status >= 400 (incluye status code, url truncada, y primeros 200 chars del body).
- **Deps:** `urllib.request`, `urllib.parse`, `json` (solo stdlib, sin requests)
- **Tests:**
- Mock de respuesta 200 con JSON
- Mock de respuesta 404 → error con status code
- Mock de respuesta con JSON invalido → error descriptivo
- Params serializados como query string
- Headers custom enviados
### http_post_json
- **Dominio:** infra
- **Lang:** Python
- **Purity:** impure
- **Signature:** `http_post_json(url: str, body: dict, headers: dict[str, str] | None = None, timeout: float = 30.0) -> dict`
- **Retorno:** Response body parseado como dict/list
- **Descripcion:** POST request con body JSON. Agrega `Content-Type: application/json` y `Accept: application/json`. Misma politica de errores que http_get_json.
- **Deps:** `urllib.request`, `json` (solo stdlib)
- **Tests:**
- Mock de POST con body serializado correctamente
- Mock de respuesta 201
- Mock de respuesta 500 → error
- Body con unicode
### http_download_file
- **Dominio:** infra
- **Lang:** Python
- **Purity:** impure
- **Signature:** `http_download_file(url: str, dest_path: str, headers: dict[str, str] | None = None, timeout: float = 120.0, chunk_size: int = 8192) -> dict`
- **Retorno:** `{"path": str, "size_bytes": int, "content_type": str}`
- **Descripcion:** Descarga un archivo por HTTP en streaming (sin cargar todo en memoria). Crea directorios intermedios si no existen. Si el archivo destino ya existe, lo sobreescribe.
- **Deps:** `urllib.request`, `os` (solo stdlib)
- **Tests:**
- Mock de descarga con contenido binario
- Directorio destino creado automaticamente
- Retorno con size correcto
- Timeout configurado en el request
### http_get_json (Go)
- **Dominio:** infra
- **Lang:** Go
- **Purity:** impure
- **Signature:** `HttpGetJSON(url string, headers map[string]string, timeout time.Duration) (map[string]any, error)`
- **Descripcion:** Misma semantica que Python. Usa `net/http` + `encoding/json`. Retorna error con status code si >= 400. Cierra body siempre (defer).
- **Deps:** `net/http`, `encoding/json`, `time` (solo stdlib)
- **Tests:**
- httptest.Server con respuesta JSON
- Status 404 → error
- Timeout → error
- Headers custom
### http_post_json (Go)
- **Dominio:** infra
- **Lang:** Go
- **Purity:** impure
- **Signature:** `HttpPostJSON(url string, body any, headers map[string]string, timeout time.Duration) (map[string]any, error)`
- **Descripcion:** Misma semantica que Python. Serializa body con json.Marshal, POST con Content-Type application/json.
- **Deps:** `net/http`, `encoding/json`, `bytes`, `time`
- **Tests:**
- httptest.Server recibe body correcto
- Status 201 → exito
- Status 500 → error con body parcial
### http_download_file (Go)
- **Dominio:** infra
- **Lang:** Go
- **Purity:** impure
- **Signature:** `HttpDownloadFile(url, destPath string, headers map[string]string, timeout time.Duration) (int64, error)`
- **Retorno:** bytes escritos, error
- **Descripcion:** Descarga en streaming con io.Copy. Crea directorios con os.MkdirAll. Usa archivo temporal + rename para atomicidad.
- **Deps:** `net/http`, `os`, `io`, `path/filepath`
- **Tests:**
- httptest.Server sirve archivo binario
- Directorio creado automaticamente
- Archivo temporal + rename (no deja basura si falla)
- Size retornado coincide
@@ -0,0 +1,104 @@
# Scheduling / Cron
Gap identificado en el registry. Reimplementar desde cero.
Los pipelines se lanzan manualmente o desde la TUI. No hay scheduling recurrente
dentro del registry. Complementario a Dagu — estas funciones son primitivas
componibles, no un scheduler completo.
## Funciones a implementar
### parse_cron_expr
- **Dominio:** core
- **Lang:** Go
- **Purity:** pure
- **Signature:** `ParseCronExpr(expr string) (CronSchedule, error)`
- **Retorno:** CronSchedule con campos Minute, Hour, DayOfMonth, Month, DayOfWeek parseados
- **Descripcion:** Parser de expresiones cron estandar (5 campos). Soporta `*`, rangos (`1-5`), listas (`1,3,5`), pasos (`*/15`), y aliases (@hourly, @daily, @weekly, @monthly). No soporta seconds ni years (no es Quartz).
- **Algoritmo:**
1. Check aliases (@hourly → "0 * * * *", etc)
2. Split por espacios, validar 5 campos
3. Por campo: expandir `*` a rango completo, parsear rangos/listas/pasos
4. Validar limites (minute 0-59, hour 0-23, etc)
- **Deps:** `strings`, `strconv`, `fmt` (solo stdlib)
- **Tests:**
- "*/15 * * * *" → minutos [0,15,30,45]
- "0 9 * * 1-5" → 9am weekdays
- "@daily" → "0 0 * * *"
- "0 9 1,15 * *" → dia 1 y 15 a las 9
- Expresion invalida → error descriptivo
- Campo fuera de rango → error
### next_cron_time
- **Dominio:** core
- **Lang:** Go
- **Purity:** pure
- **Signature:** `NextCronTime(schedule CronSchedule, after time.Time) time.Time`
- **Retorno:** Proximo time.Time que cumple el schedule despues de `after`
- **Descripcion:** Calcula la proxima ejecucion de un cron schedule. Avanza minuto a minuto desde `after` hasta encontrar un match. Limit de 366 dias para evitar loops infinitos en schedules imposibles.
- **Algoritmo:**
1. Truncar `after` al minuto, avanzar 1 minuto
2. Check month → si no match, avanzar al primer dia del proximo mes valido
3. Check day of month y day of week → si no match, avanzar al proximo dia
4. Check hour → si no match, avanzar a la proxima hora
5. Check minute → si no match, avanzar al proximo minuto
- **Deps:** `time`
- **Tests:**
- "0 * * * *" desde 14:30 → 15:00
- "0 9 * * 1" desde viernes → proximo lunes 9:00
- "0 0 29 2 *" → proximo 29 de febrero
- Schedule imposible → panic o error despues de limit
### cron_ticker
- **Dominio:** infra
- **Lang:** Go
- **Purity:** impure (goroutine + channel + time)
- **Signature:** `CronTicker(schedule CronSchedule, ctx context.Context) <-chan time.Time`
- **Retorno:** Channel que emite un time.Time en cada tick del schedule
- **Descripcion:** Crea un ticker que emite en los momentos definidos por el cron schedule. Usa time.NewTimer internamente, recalculando el proximo tick despues de cada emision. Se detiene al cancelar el context.
- **Deps:** `time`, `context`
- **Tests:**
- Ticker con schedule "* * * * *" emite cada minuto (test con clock mock o schedule rapido)
- Context cancel detiene el ticker
- Channel se cierra al cancelar
### parse_cron_expr (Python)
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `parse_cron_expr(expr: str) -> dict`
- **Retorno:** `{"minute": list[int], "hour": list[int], "day_of_month": list[int], "month": list[int], "day_of_week": list[int]}`
- **Descripcion:** Misma semantica que Go. Dict en vez de struct.
- **Tests:** Mismos casos que Go
### next_cron_time (Python)
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `next_cron_time(schedule: dict, after: datetime) -> datetime`
- **Descripcion:** Misma semantica que Go.
- **Tests:** Mismos casos que Go
## Tipo a implementar
### CronSchedule (Go)
- **Dominio:** core
- **Lang:** Go
- **Algebraic:** product
- **Definicion:**
```go
type CronSchedule struct {
Minute []int
Hour []int
DayOfMonth []int
Month []int
DayOfWeek []int
Raw string // expresion original
}
```
@@ -0,0 +1,124 @@
# Cache / Memoizacion persistente
Gap identificado en el registry. Reimplementar desde cero.
memoize_go_core es in-memory y se pierde entre ejecuciones. Para pipelines que
corren repetidamente (especialmente con llamadas LLM costosas), un cache
persistente con TTL evita recalculos.
## Funciones a implementar
### cache_to_sqlite
- **Dominio:** infra
- **Lang:** Python
- **Purity:** impure (I/O disco)
- **Signature:** `cache_to_sqlite(db_path: str, namespace: str = "default") -> CacheStore`
- **Retorno:** Objeto CacheStore con metodos get/set/delete/clear/stats
- **Descripcion:** Cache key-value persistido en SQLite. Cada namespace es una tabla logica (filtro en la query, no tabla separada). Keys son strings, values se serializan con json. TTL en segundos, 0 = sin expiracion. Limpieza de expirados en cada get (lazy eviction).
- **Schema SQLite:**
```sql
CREATE TABLE IF NOT EXISTS cache (
namespace TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL, -- JSON serializado
created_at REAL NOT NULL, -- time.time()
expires_at REAL, -- NULL = no expira
PRIMARY KEY (namespace, key)
);
```
- **API del CacheStore:**
```python
class CacheStore:
def get(self, key: str) -> any | None: ... # None si miss o expirado
def set(self, key: str, value: any, ttl: float = 0) -> None: ...
def delete(self, key: str) -> bool: ...
def clear(self) -> int: ... # retorna filas eliminadas
def stats(self) -> dict: ... # {"hits": N, "misses": N, "size": N}
def get_or_set(self, key: str, factory: callable, ttl: float = 0) -> any: ...
```
- **Deps:** `sqlite3`, `json`, `time` (solo stdlib)
- **Tests:**
- Set y get basico
- TTL expirado → None
- TTL 0 → nunca expira
- get_or_set con factory que solo se llama en miss
- Namespaces independientes
- Clear elimina solo el namespace
- Stats contadores correctos
- Concurrencia (threading basico)
### cache_to_file
- **Dominio:** infra
- **Lang:** Python
- **Purity:** impure
- **Signature:** `cache_to_file(cache_dir: str, namespace: str = "default") -> FileCache`
- **Retorno:** Objeto FileCache con misma API que CacheStore
- **Descripcion:** Cache key-value donde cada entry es un archivo JSON en disco. Mas simple que SQLite, mejor para valores grandes (PDFs procesados, embeddings). Key se hashea con SHA-256 para nombre de archivo. Metadata (ttl, created_at) en un sidecar `.meta` file.
- **Estructura en disco:**
```
cache_dir/
namespace/
{sha256_key}.json # valor
{sha256_key}.meta # {"created_at": ..., "expires_at": ..., "original_key": ...}
```
- **API:** Misma interfaz que CacheStore (get, set, delete, clear, stats, get_or_set)
- **Deps:** `os`, `json`, `hashlib`, `time` (solo stdlib)
- **Tests:**
- Set y get basico
- TTL expirado → None
- Archivo .meta con metadata correcta
- Clear elimina el directorio del namespace
- Key con caracteres especiales → hash seguro
### cache_decorator
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (usa cache store)
- **Signature:** `cache_decorator(store: CacheStore | FileCache, ttl: float = 0, key_fn: callable | None = None)`
- **Retorno:** Decorator que cachea resultados de funciones
- **Descripcion:** Decorator que cachea el resultado de una funcion en un store persistente. Por defecto, la key se genera hasheando `(func.__name__, args, sorted(kwargs))`. `key_fn` permite custom key generation.
- **Uso:**
```python
store = cache_to_sqlite("cache.db")
@cache_decorator(store, ttl=3600)
def call_llm(prompt: str) -> str:
... # llamada costosa
result = call_llm("explain X") # primera vez: llama LLM
result = call_llm("explain X") # segunda vez: desde cache
```
- **Deps:** `functools`, `json`, `hashlib`
- **Tests:**
- Funcion llamada una vez, segunda vez desde cache
- TTL expirado → llama de nuevo
- key_fn custom
- Argumentos distintos → keys distintas
- Funciona con async (detect asyncio.iscoroutinefunction)
### cache_to_sqlite (Go)
- **Dominio:** infra
- **Lang:** Go
- **Purity:** impure
- **Signature:** `CacheToSQLite(dbPath, namespace string) (*SQLiteCache, error)`
- **Retorno:** *SQLiteCache con metodos Get/Set/Delete/Clear
- **Descripcion:** Misma semantica que Python. Valores almacenados como JSON string. Get retorna `([]byte, bool)` — el caller deserializa.
- **API:**
```go
type SQLiteCache struct { ... }
func (c *SQLiteCache) Get(key string) ([]byte, bool) { ... }
func (c *SQLiteCache) Set(key string, value []byte, ttl time.Duration) error { ... }
func (c *SQLiteCache) Delete(key string) error { ... }
func (c *SQLiteCache) Clear() (int64, error) { ... }
func (c *SQLiteCache) GetOrSet(key string, factory func() ([]byte, error), ttl time.Duration) ([]byte, error) { ... }
```
- **Deps:** `database/sql`, `encoding/json`, `time`, `crypto/sha256`
- **Tests:**
- Set/Get basico
- TTL expirado
- GetOrSet con factory
- Concurrencia (goroutines)
@@ -0,0 +1,122 @@
# Diff / Merge para operations.db y grafos
Gap identificado en el registry. Reimplementar desde cero.
Cuando un pipeline corre multiples veces, no hay forma de comparar resultados
entre ejecuciones. Necesario para detectar drift, validar estabilidad, y
mergear grafos de conocimiento de distintas fuentes.
## Funciones a implementar
### diff_entities
- **Dominio:** datascience
- **Lang:** Python
- **Purity:** pure
- **Signature:** `diff_entities(before: list[dict], after: list[dict], key: str = "id") -> dict`
- **Retorno:**
```python
{
"added": list[dict], # entities nuevas (key en after, no en before)
"removed": list[dict], # entities eliminadas (key en before, no en after)
"modified": list[dict], # entities con cambios: {"key": str, "changes": {"field": {"old": ..., "new": ...}}}
"unchanged": int, # count de entities sin cambios
"summary": str # "3 added, 1 removed, 5 modified, 42 unchanged"
}
```
- **Descripcion:** Compara dos snapshots de entities (tipicamente de dos ejecuciones del mismo pipeline). Detecta añadidas, eliminadas, y modificadas con detalle campo a campo. Ignora campos de metadata temporal (created_at, updated_at) por defecto.
- **Parametros opcionales:**
- `ignore_fields: list[str] = ["created_at", "updated_at"]` — campos a excluir de la comparacion
- `compare_fields: list[str] | None = None` — si se da, solo compara estos campos
- **Deps:** ninguna
- **Tests:**
- Entity añadida
- Entity eliminada
- Entity modificada con detalle de campos
- Entities identicas → unchanged
- ignore_fields funciona
- compare_fields filtra correctamente
- Lista vacia vs lista con datos
### diff_relations
- **Dominio:** datascience
- **Lang:** Python
- **Purity:** pure
- **Signature:** `diff_relations(before: list[dict], after: list[dict], key: tuple[str, str, str] = ("source_id", "target_id", "relation_type")) -> dict`
- **Retorno:** Misma estructura que diff_entities pero con key compuesta
- **Descripcion:** Compara relaciones entre dos snapshots. Key compuesta porque las relaciones se identifican por (source, target, type), no por un solo ID.
- **Deps:** ninguna
- **Tests:**
- Relacion añadida
- Relacion eliminada
- Relacion con metadata modificada (mismo source/target/type, distinto weight)
- Key compuesta funciona correctamente
### detect_drift
- **Dominio:** datascience
- **Lang:** Python
- **Purity:** pure
- **Signature:** `detect_drift(history: list[dict], current: dict, fields: list[str], threshold: float = 2.0) -> list[dict]`
- **Retorno:** Lista de `{"field": str, "current": float, "mean": float, "std": float, "z_score": float, "drifted": bool}`
- **Descripcion:** Detecta drift estadistico comparando metricas de la ejecucion actual contra el historial. Usa z-score — si |z| > threshold, el campo ha drifteado. Pensado para comparar metrics de executions sucesivas en operations.db.
- **Ejemplo:**
```python
history = [
{"records_out": 100, "duration_ms": 500},
{"records_out": 105, "duration_ms": 480},
{"records_out": 98, "duration_ms": 510},
]
current = {"records_out": 50, "duration_ms": 2000}
detect_drift(history, current, ["records_out", "duration_ms"])
# [{"field": "records_out", "current": 50, "mean": 101.0, "std": 3.6, "z_score": -14.2, "drifted": True},
# {"field": "duration_ms", "current": 2000, "mean": 496.7, "std": 15.3, "z_score": 98.3, "drifted": True}]
```
- **Deps:** `math` (solo stdlib)
- **Tests:**
- Campo con drift claro (z > threshold)
- Campo estable (z < threshold)
- Historial con un solo punto → std=0, no puede calcular → drifted=False con nota
- Historial vacio → todos drifted=False
- Threshold custom
### merge_graphs
- **Dominio:** datascience
- **Lang:** Python
- **Purity:** pure
- **Signature:** `merge_graphs(graphs: list[dict], entity_key: str = "name", similarity_threshold: float = 0.85) -> dict`
- **Retorno:**
```python
{
"entities": list[dict], # entities mergeadas (deduplicadas)
"relations": list[dict], # relaciones mergeadas
"merge_log": list[dict] # registro de merges: {"merged": [id1, id2], "into": id, "similarity": float}
}
```
- **Descripcion:** Mergea multiples grafos de conocimiento (de distintas fuentes o ejecuciones) en uno solo. Deduplicacion de entities por similitud de nombre (Levenshtein normalizado). Relaciones se re-apuntan a las entities mergeadas. Atributos se combinan (union de campos, ultimo gana en conflictos).
- **Algoritmo:**
1. Juntar todas las entities de todos los grafos
2. Para cada par de entities con similitud de nombre >= threshold, mergear
3. Elegir entity canonica (la que tiene mas campos no-null)
4. Re-apuntar relaciones al ID canonico
5. Deduplicar relaciones identicas (mismo source, target, type)
6. Registrar cada merge en merge_log
- **Deps del registry:** `levenshtein_distance_py_cybersecurity` (o reimplementar inline)
- **Tests:**
- Dos grafos con entity duplicada → merge
- Entities similares pero bajo threshold → no merge
- Relaciones re-apuntadas correctamente
- Merge log registra cada merge
- Tres grafos → merge transitivo
- Grafos sin overlap → concatenacion simple
### diff_entities (Go)
- **Dominio:** datascience
- **Lang:** Go
- **Purity:** pure
- **Signature:** `DiffEntities(before, after []map[string]any, key string, ignoreFields []string) map[string]any`
- **Descripcion:** Misma semantica que Python. Retorno como map para serializacion JSON directa.
- **Tests:** Mismos casos que Python
@@ -0,0 +1,73 @@
# Extract Entities from Text (LLM-based)
Diseño original para fuzzygraph.
## Funcion a implementar
### extract_entities_llm
- **Dominio:** datascience
- **Lang:** Python
- **Purity:** impure (llama LLM)
- **Signature:**
```python
def extract_entities_llm(
text: str,
entity_schema: list[dict],
llm_chat_json: Callable[[list[dict]], dict],
language_instruction: str = "Respond in English.",
) -> list[EntityCandidate]:
```
- **Parametros:**
- `text`: chunk de texto a analizar
- `entity_schema`: lista de tipos con metadata fields, formato:
```python
[
{"type_ref": "osint_person_go_cybersecurity", "label": "Person",
"metadata_fields": ["full_name", "alias", "nationality", "dob", "risk_score"]},
{"type_ref": "osint_domain_go_cybersecurity", "label": "Domain",
"metadata_fields": ["fqdn", "registrar", "created_date"]},
...
]
```
- `llm_chat_json`: funcion que recibe messages y retorna dict (inyeccion de dependencia, no acoplado a OpenAI)
- `language_instruction`: instruccion de idioma para el LLM
- **Retorno:** `list[EntityCandidate]` (ver spec fg_09)
- **Algoritmo:**
1. Construir system prompt con el schema de entity types:
```
You are an entity extraction expert. Given text, extract all entities
matching these types. For each entity, provide: name, type_ref,
attributes (matching the metadata_fields for that type), and a
confidence score (0.0-1.0).
Entity types:
- Person (type_ref: osint_person_go_cybersecurity)
fields: full_name, alias, nationality, dob, risk_score
- Domain (type_ref: osint_domain_go_cybersecurity)
fields: fqdn, registrar, created_date
...
Output JSON: {"entities": [{"name": "...", "type_ref": "...", "attributes": {...}, "confidence": 0.9}]}
Rules:
- Only extract entities explicitly mentioned in the text
- Use the exact type_ref from the schema
- Leave unknown attributes as null
- Confidence: 1.0 = explicitly named, 0.7 = strongly implied, 0.5 = weakly implied
```
2. User message: el texto del chunk
3. Llamar `llm_chat_json([system, user])` → parsear respuesta
4. Validar: cada entity tiene name y type_ref valido (del schema)
5. Retornar lista de EntityCandidate
- **Error handling:**
- JSON invalido del LLM: retornar lista vacia + warning
- type_ref no en schema: descartar esa entidad
- **Deps:** ninguna especifica (el LLM se inyecta)
- **Error type:** ValueError (schema vacio)
- **Tests:**
- Texto con entidades claras (personas, dominios)
- Texto sin entidades (retorna [])
- LLM retorna JSON mal formado (retorna [] con warning)
- type_ref invalido en respuesta (se descarta)
- Confidence se propaga correctamente
@@ -0,0 +1,64 @@
# Extract Relations from Text (LLM-based)
Diseño original para fuzzygraph.
## Funcion a implementar
### extract_relations_llm
- **Dominio:** datascience
- **Lang:** Python
- **Purity:** impure (llama LLM)
- **Signature:**
```python
def extract_relations_llm(
text: str,
entities: list[EntityCandidate],
relation_types: list[str],
llm_chat_json: Callable[[list[dict]], dict],
language_instruction: str = "Respond in English.",
) -> list[RelationCandidate]:
```
- **Parametros:**
- `text`: chunk de texto (mismo que se uso para extraer entidades)
- `entities`: entidades ya extraidas de este chunk (para que el LLM sepa entre que entidades buscar relaciones)
- `relation_types`: tipos de relacion permitidos, ej: `["funds", "employs", "communicates_with", "owns", "operates", "controls", "affiliated_with", "located_at", "resolves_to", "hosts", "exploits", "attributed_to", "related_to"]`
- `llm_chat_json`: funcion inyectada
- **Retorno:** `list[RelationCandidate]` (ver spec fg_09)
- **Algoritmo:**
1. Si `len(entities) < 2`: retornar [] (necesitas al menos 2 entidades para una relacion)
2. Construir system prompt:
```
You are a relation extraction expert. Given text and a list of
entities already extracted, identify relationships between them.
Entities found in this text:
- "John Smith" (Person)
- "Acme Corp" (Organization)
- "192.168.1.1" (IP Address)
Allowed relation types: funds, employs, communicates_with, ...
Output JSON: {"relations": [
{"from_name": "Acme Corp", "to_name": "John Smith",
"relation_type": "employs", "description": "...", "confidence": 0.8}
]}
Rules:
- Only extract relations explicitly stated or strongly implied
- from_name and to_name must match entity names exactly
- relation_type must be from the allowed list
- Confidence: 1.0 = explicitly stated, 0.7 = strongly implied
```
3. User message: el texto
4. Parsear respuesta, validar from/to contra nombres de entities
5. Retornar lista de RelationCandidate
- **Error handling:**
- from_name o to_name no matchean ninguna entidad: descartar relacion
- relation_type no en lista: usar "related_to" como fallback
- **Deps:** ninguna especifica
- **Tests:**
- Texto con 2 entidades relacionadas
- Texto con entidades pero sin relacion (retorna [])
- Menos de 2 entidades (retorna [])
- LLM inventa entidad que no existe (se descarta)
@@ -0,0 +1,50 @@
# Deduplicate Entities (fuzzy matching)
Diseño original para fuzzygraph.
## Funcion a implementar
### deduplicate_entities
- **Dominio:** datascience
- **Lang:** Python
- **Purity:** pure
- **Signature:**
```python
def deduplicate_entities(
candidates: list[EntityCandidate],
name_threshold: float = 0.85,
same_type_only: bool = True,
) -> DeduplicationResult:
```
- **Retorno:** `DeduplicationResult` con entities deduplicadas y merge log
- **Descripcion:** Agrupa entidades candidatas que refieren a la misma entidad real usando fuzzy matching de nombres. Cuando dos entidades son suficientemente similares (y del mismo tipo si `same_type_only=True`), se mergean en una sola combinando atributos.
- **Algoritmo:**
1. **Normalizar nombres:** `normalize_entity_name()` sobre cada candidato
2. **Agrupar por tipo** (si same_type_only): reducir comparaciones
3. **Comparacion pairwise dentro de cada grupo:**
- Calcular similitud: `1.0 - (levenshtein_distance(a, b) / max(len(a), len(b)))`
- Tambien comparar por tokens: `jaccard_similarity(tokens_a, tokens_b)`
- Score final: `max(levenshtein_sim, jaccard_sim)`
- Si score >= `name_threshold`: agrupar como misma entidad
4. **Union-Find** para clusters transitivos: si A~B y B~C, entonces {A,B,C} es un cluster
5. **Merge por cluster:**
- Nombre: el del candidato con mayor confidence
- Atributos: `merge_entity_attributes()` — combinar non-null de todos los candidatos
- Confidence: max del cluster
- Source chunks: union de todos
6. Retornar DeduplicationResult con entidades merged y log de merges
- **Optimizacion:** Para N candidatos, pairwise es O(N^2). Aceptable para <1000 entidades (caso tipico por documento). Si necesita escalar: pre-filtrar por primera letra o n-gram index.
- **Heuristicas adicionales de match:**
- Nombre contenido: "John" matchea "John Smith" si son del mismo tipo (score bonus +0.3)
- Acronimos: "FBI" matchea "Federal Bureau of Investigation" — detectar si un nombre es acronimo de otro
- IPs/emails/domains: matching exacto normalizado (no fuzzy)
- **Deps:** `levenshtein_distance`, `jaccard_similarity` (ya en registry)
- **Tests:**
- "John Smith" y "Smith, John" → merge
- "Google" y "Google LLC" → merge
- "192.168.1.1" y "192.168.1.1" → merge (exacto)
- "John Smith" (Person) y "John Smith" (Organization) → NO merge (distinto tipo)
- Clusters transitivos: A~B, B~C → {A,B,C}
- Entidades sin duplicados → sin cambios
- Confidence y atributos se mergean correctamente
@@ -0,0 +1,36 @@
# Deduplicate Relations
Diseño original para fuzzygraph.
## Funcion a implementar
### deduplicate_relations
- **Dominio:** datascience
- **Lang:** Python
- **Purity:** pure
- **Signature:**
```python
def deduplicate_relations(
relations: list[RelationCandidate],
entity_id_map: dict[str, str],
) -> list[RelationCandidate]:
```
- **Parametros:**
- `relations`: relaciones candidatas (con from_name/to_name originales)
- `entity_id_map`: mapping de nombre normalizado → entity_id final (output de deduplicate_entities). Permite resolver nombres que fueron mergeados: si "John Smith" y "Smith, John" se mergearon en entity_123, ambos nombres mapean a entity_123.
- **Retorno:** lista deduplicada de RelationCandidate con from/to resueltos a IDs finales
- **Algoritmo:**
1. **Resolver nombres a IDs:** Para cada relacion, mapear from_name y to_name al entity_id via entity_id_map. Si un nombre no tiene match, intentar fuzzy match contra las keys del map.
2. **Deduplicar por (from_id, to_id, relation_type):**
- Si multiples relaciones tienen el mismo (from, to, type): mergear
- Descripcion: concatenar descripciones unicas
- Confidence: max
- Self-loops (from_id == to_id): descartar
3. Retornar lista limpia
- **Deps:** `levenshtein_distance` (para fuzzy match de nombres no resueltos)
- **Tests:**
- 2 relaciones identicas (from, to, type) → 1
- Relacion con nombre mergeado → se resuelve al ID correcto
- Self-loop → descartado
- Relacion con nombre no mapeado → intenta fuzzy match, si falla descarta con warning
@@ -0,0 +1,51 @@
# Build Schema Prompts
Diseño original para fuzzygraph.
## Funciones a implementar
### 1. build_entity_schema_prompt
- **Dominio:** datascience
- **Lang:** Python
- **Purity:** pure
- **Signature:** `build_entity_schema_prompt(entity_presets: list[dict]) -> str`
- **Descripcion:** Genera la seccion del system prompt que describe los entity types disponibles para extraccion. Formatea los presets del registry en texto legible para el LLM.
- **Input:** Lista de presets tal como estan en fuzzygraph:
```python
[
{"type_ref": "osint_person_go_cybersecurity", "label": "Person",
"metadata_fields": ["full_name", "alias", "nationality", "dob", "risk_score"]},
...
]
```
- **Output:** String formateado:
```
Entity types available for extraction:
1. Person (type_ref: osint_person_go_cybersecurity)
Attributes: full_name, alias, nationality, dob, risk_score
2. Organization (type_ref: osint_organization_go_cybersecurity)
Attributes: legal_name, country, sector, founded, risk_score
...
```
- **Tests:** lista con varios presets, lista vacia, preset sin metadata_fields
### 2. build_relation_schema_prompt
- **Dominio:** datascience
- **Lang:** Python
- **Purity:** pure
- **Signature:** `build_relation_schema_prompt(relation_types: list[str]) -> str`
- **Descripcion:** Genera la seccion del system prompt con los tipos de relacion permitidos.
- **Input:** `["funds", "employs", "communicates_with", ...]`
- **Output:**
```
Allowed relation types:
funds, employs, communicates_with, owns, operates, controls,
affiliated_with, located_at, resolves_to, registered_by, hosts,
exploits, attributed_to, related_to
```
- **Tests:** lista normal, lista vacia
@@ -0,0 +1,41 @@
# Normalize Entity Name
Diseño original para fuzzygraph.
## Funcion a implementar
### normalize_entity_name
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `normalize_entity_name(name: str, entity_type: str = "") -> str`
- **Descripcion:** Normaliza el nombre de una entidad para comparacion y deduplicacion. Aplica reglas diferentes segun el tipo de entidad.
- **Algoritmo:**
1. Strip whitespace
2. **Si tipo es IP/email/domain/crypto_wallet/phone:** normalizar formato tecnico
- IP: strip, lower (para IPv6)
- Email: lower, strip
- Domain: lower, strip, remover trailing dot, remover "www." prefix
- Crypto wallet: strip (case-sensitive, no lowercase — Bitcoin addresses son case-sensitive)
- Phone: remover espacios, guiones, parentesis, mantener solo digitos y +
3. **Si tipo es persona:** normalizar nombre humano
- Remover titulos: "Dr.", "Mr.", "Mrs.", "Prof.", etc.
- "Apellido, Nombre" → "Nombre Apellido" (detectar formato con coma)
- Colapsar espacios multiples
- Title case
4. **Si tipo es organizacion:** normalizar nombre corporativo
- Remover sufijos legales: "Inc.", "LLC", "Ltd.", "Corp.", "S.A.", "GmbH", etc.
- Strip, colapsar espacios
5. **Default:** lower, strip, colapsar espacios
- **Deps:** `re`
- **Tests:**
- " John Smith " → "John Smith"
- "Smith, John" → "John Smith"
- "Dr. Jane Doe" → "Jane Doe"
- "Google LLC" → "Google"
- "ACME Corp." → "Acme"
- "192.168.1.1" → "192.168.1.1" (sin cambio)
- "user@GMAIL.com" → "user@gmail.com"
- "www.example.com." → "example.com"
- "+1 (555) 123-4567" → "+15551234567"
@@ -0,0 +1,34 @@
# Merge Entity Attributes
Diseño original para fuzzygraph.
## Funcion a implementar
### merge_entity_attributes
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `merge_entity_attributes(attr_list: list[dict]) -> dict`
- **Descripcion:** Combina atributos de multiples candidatos de la misma entidad. Cuando el mismo campo tiene valores diferentes, aplica heuristicas de resolucion.
- **Algoritmo:**
1. Para cada campo presente en cualquier candidato:
- Recopilar todos los valores non-null
- Si 0 valores: null
- Si 1 valor: usar ese
- Si todos iguales: usar ese
- Si diferentes:
- **Numerico** (risk_score, balance, cvss): usar max
- **Fecha** (first_seen, created_date): usar min (mas antigua)
- **Fecha** (last_seen, expires_date): usar max (mas reciente)
- **Lista** (name_servers): union
- **String** (description, alias): usar el mas largo, o concatenar si ambos aportan info
- **Boolean** (verified, exploited): usar True si alguno es True (OR)
2. Retornar dict merged
- **Deps:** ninguna
- **Tests:**
- Atributos complementarios (A tiene full_name, B tiene nationality) → ambos
- Atributos conflictivos en risk_score → max
- Atributos first_seen conflictivos → min
- Todos null → null
- Listas → union sin duplicados
@@ -0,0 +1,71 @@
# Extraction Pipeline (orquestador)
Diseño original para fuzzygraph.
## Pipeline a implementar
### extraction_pipeline
- **Dominio:** pipelines
- **Lang:** Python
- **Kind:** pipeline
- **Purity:** impure
- **Signature:**
```python
def extraction_pipeline(
file_path: str,
entity_presets: list[dict],
relation_types: list[str],
llm_chat_json: Callable[[list[dict]], dict],
chunk_size: int = 500,
chunk_overlap: int = 50,
confidence_threshold: float = 0.5,
dedup_threshold: float = 0.85,
on_progress: Callable[[str, float], None] | None = None,
) -> ExtractionResult:
```
- **Descripcion:** Pipeline completa de extraccion de entidades y relaciones desde un documento. Orquesta todas las funciones del stack.
- **uses_functions:**
- `extract_text_from_file` (mf_09)
- `preprocess_text` (mf_02)
- `split_text_into_chunks` (mf_01)
- `build_entity_schema_prompt` (fg_05)
- `build_relation_schema_prompt` (fg_05)
- `extract_entities_llm` (fg_01)
- `extract_relations_llm` (fg_02)
- `deduplicate_entities` (fg_03)
- `deduplicate_relations` (fg_04)
- **Algoritmo:**
1. **Extract:** `extract_text_from_file(file_path)` → texto crudo
2. **Preprocess:** `preprocess_text(text)` → texto limpio
3. **Split:** `split_text_into_chunks(text, chunk_size, chunk_overlap)` → chunks
4. **Extract entities per chunk:** Para cada chunk (con retry):
- `extract_entities_llm(chunk, entity_presets, llm_chat_json)`
- Anotar `source_chunk_index` en cada candidato
- progress callback: `(f"Extracting entities from chunk {i}/{n}", i/n * 0.4)`
5. **Flatten + filter:** Juntar todas las entidades, filtrar por `confidence >= confidence_threshold`
6. **Deduplicate entities:** `deduplicate_entities(all_entities, dedup_threshold)`
7. **Extract relations per chunk:** Para cada chunk:
- Obtener entidades relevantes de ese chunk
- `extract_relations_llm(chunk, chunk_entities, relation_types, llm_chat_json)`
- progress callback: `("Extracting relations...", 0.4 + i/n * 0.4)`
8. **Deduplicate relations:** `deduplicate_relations(all_relations, entity_id_map)`
9. **Return** ExtractionResult con entities, relations, stats
- **Progress:** 0-40% entity extraction, 40-80% relation extraction, 80-100% dedup
- **Error type:** FileNotFoundError, ValueError
- **Tests:** documento con entidades y relaciones, documento vacio, documento sin entidades detectables
## Integracion con fuzzygraph
La pipeline retorna `ExtractionResult` que contiene listas de entidades y relaciones listas para insertar en `operations.db` via `fn ops entity add` / `fn ops relation add`. El mapping a fuzzygraph seria:
```python
for entity in result.entities:
# entity.name → ops entity name
# entity.type_ref → ops entity type_ref
# entity.attributes → ops entity metadata (JSON)
db.add_entity(name=entity.name, type_ref=entity.type_ref, metadata=entity.attributes)
for relation in result.relations:
db.add_relation(name=relation.relation_type, from_entity=relation.from_id, to_entity=relation.to_id)
```
@@ -0,0 +1,102 @@
# Tipos: Extraction Pipeline
Diseño original para fuzzygraph.
## Tipos a implementar
### 1. EntityCandidate (product)
- **Dominio:** datascience
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class EntityCandidate:
name: str # nombre extraido del texto
name_normalized: str = "" # nombre normalizado (se llena en dedup)
type_ref: str = "" # registry type ID (ej: osint_person_go_cybersecurity)
type_label: str = "" # label humano (ej: "Person")
attributes: dict = field(default_factory=dict) # metadata extraida
confidence: float = 0.0 # 0.0-1.0
source_chunk_indices: list[int] = field(default_factory=list) # chunks donde aparecio
merged_from: list[str] = field(default_factory=list) # nombres originales si fue mergeado
```
- **Metodos:** `to_dict() -> dict`
- **Descripcion:** Candidato de entidad extraido por el LLM. Puede venir de un solo chunk o ser el resultado de mergear multiples extracciones. `merged_from` rastrea los nombres originales para debugging.
### 2. RelationCandidate (product)
- **Dominio:** datascience
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class RelationCandidate:
from_name: str # nombre de la entidad origen
to_name: str # nombre de la entidad destino
from_id: str = "" # entity ID resuelto (se llena en dedup)
to_id: str = "" # entity ID resuelto
relation_type: str = "" # tipo de relacion (de relationPresets)
description: str = "" # descripcion textual de la relacion
confidence: float = 0.0 # 0.0-1.0
source_chunk_index: int = -1 # chunk donde se extrajo
```
- **Metodos:** `to_dict() -> dict`
### 3. ExtractionResult (product)
- **Dominio:** datascience
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class ExtractionResult:
entities: list[EntityCandidate] # entidades finales (deduplicadas)
relations: list[RelationCandidate] # relaciones finales (deduplicadas)
stats: ExtractionStats = field(default_factory=lambda: ExtractionStats())
```
### 4. ExtractionStats (product)
- **Dominio:** datascience
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class ExtractionStats:
total_chunks: int = 0
total_chars: int = 0
raw_entities_count: int = 0 # antes de dedup
final_entities_count: int = 0 # despues de dedup
entities_merged: int = 0 # cuantas se mergearon
raw_relations_count: int = 0
final_relations_count: int = 0
relations_merged: int = 0
relations_discarded: int = 0 # self-loops + sin match
entity_types_found: dict[str, int] = field(default_factory=dict) # type_ref → count
relation_types_found: dict[str, int] = field(default_factory=dict)
processing_time_seconds: float = 0.0
```
- **Descripcion:** Estadisticas del proceso de extraccion. Util para reporting y debugging.
### 5. DeduplicationResult (product)
- **Dominio:** datascience
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class DeduplicationResult:
entities: list[EntityCandidate] # entidades finales
entity_id_map: dict[str, str] # nombre_normalizado → entity_id (para resolver relations)
name_to_id: dict[str, str] # todos los nombres originales → entity_id (incluyendo aliases)
merge_log: list[dict] = field(default_factory=list) # [{"merged": ["John Smith", "Smith, John"], "into": "John Smith", "score": 0.92}]
total_before: int = 0
total_after: int = 0
```
- **Descripcion:** Resultado de deduplicacion de entidades. El `name_to_id` mapea TODOS los nombres originales (incluyendo los mergeados) a su ID final, permitiendo resolver relaciones que usan cualquier variante del nombre.
@@ -0,0 +1,34 @@
# Split Text into Chunks (sentence-boundary aware)
Fuente conceptual: MiroFish `backend/app/utils/file_parser.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funcion a implementar
### split_text_into_chunks
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `split_text_into_chunks(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]`
- **Descripcion:** Divide texto en chunks de tamaño fijo con overlap, intentando cortar en limites de oracion para no romper frases a mitad.
- **Algoritmo:**
1. Si `len(text) <= chunk_size`: retornar `[text]` (o `[]` si vacio)
2. Sliding window con `start = 0`:
- `end = start + chunk_size`
- Si `end < len(text)`: buscar el ultimo separador de oracion en `text[start:end]`
- Separadores en orden de prioridad: `。`, ``, ``, `.\n`, `!\n`, `?\n`, `\n\n`, `. `, `! `, `? `
- Solo aceptar si el separador esta despues del 30% del chunk (evitar chunks muy cortos)
- Si se encuentra: ajustar `end` al separador + len(sep)
- Extraer chunk, strip, anadir si no vacio
- `start = end - overlap` (siguiente chunk empieza con overlap del anterior)
3. Retornar lista de chunks
- **Diferencia con smart_split_content (OpenViking):** Este divide por caracteres con overlap fijo y busca separadores de oracion. El de OpenViking divide por parrafos (doble newline) sin overlap. Son complementarios.
- **Deps:** ninguna
- **Tests:**
- Texto corto (cabe en 1 chunk)
- Texto largo con oraciones (corta en `.`)
- Texto CJK con `。` como separador
- Texto sin separadores (corta en chunk_size exacto)
- Overlap funciona (inicio del chunk N+1 = final del chunk N - overlap)
- Texto vacio → []
@@ -0,0 +1,36 @@
# Preprocess Text + Stats
Fuente conceptual: MiroFish `backend/app/services/text_processor.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funciones a implementar
### 1. preprocess_text
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `preprocess_text(text: str) -> str`
- **Descripcion:** Normaliza whitespace y newlines de un texto crudo. Util como paso previo a chunking o indexing.
- **Algoritmo:**
1. Normalizar line endings: `\r\n``\n`, `\r``\n`
2. Reducir 3+ newlines consecutivos a maximo 2: `\n{3,}``\n\n`
3. Strip whitespace de cada linea
4. Strip global
- **Deps:** `re`
- **Tests:** texto con \r\n (Windows), texto con muchos newlines, texto con espacios leading/trailing
### 2. get_text_stats
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `get_text_stats(text: str) -> dict`
- **Retorno:** `{"total_chars": int, "total_lines": int, "total_words": int}`
- **Descripcion:** Estadisticas basicas de un texto: caracteres, lineas, palabras.
- **Algoritmo:**
1. `total_chars = len(text)`
2. `total_lines = text.count('\n') + 1`
3. `total_words = len(text.split())`
- **Deps:** ninguna
- **Tests:** texto normal, texto vacio, texto con solo newlines
@@ -0,0 +1,28 @@
# To PascalCase
Fuente conceptual: MiroFish `backend/app/services/ontology_generator.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funcion a implementar
### to_pascal_case
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `to_pascal_case(name: str) -> str`
- **Descripcion:** Convierte cualquier formato de nombre (snake_case, camelCase, kebab-case, mixto) a PascalCase.
- **Algoritmo:**
1. Split por caracteres no alfanumericos: `re.split(r'[^a-zA-Z0-9]+', name)`
2. Cada parte: split adicional por boundary camelCase: `re.sub(r'([a-z])([A-Z])', r'\1_\2', part).split('_')`
3. Capitalizar primera letra de cada word, join
4. Si resultado vacio: retornar `"Unknown"`
- **Ejemplos:**
- `"works_for"``"WorksFor"`
- `"camelCaseExample"``"CamelCaseExample"`
- `"person"``"Person"`
- `"SCREAMING_SNAKE"``"ScreamingSnake"`
- `"kebab-case"``"KebabCase"`
- `""``"Unknown"`
- **Deps:** `re`
- **Tests:** snake_case, camelCase, UPPER_SNAKE, kebab-case, PascalCase (idempotente), string vacio, con numeros
@@ -0,0 +1,48 @@
# LLM Response Cleaners
Fuente conceptual: MiroFish `backend/app/utils/llm_client.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funciones a implementar
### 1. strip_think_tags
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `strip_think_tags(text: str) -> str`
- **Descripcion:** Remueve tags `<think>...</think>` que algunos modelos (MiniMax, DeepSeek) incluyen en sus respuestas como "chain of thought" interno.
- **Algoritmo:** `re.sub(r'<think>[\s\S]*?</think>', '', text).strip()`
- **Deps:** `re`
- **Tests:** texto sin tags (sin cambio), texto con think tags, tags multilinea, multiples tags
### 2. strip_markdown_codeblock
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `strip_markdown_codeblock(text: str) -> str`
- **Descripcion:** Remueve wrapping de code blocks markdown que modelos a veces anaden al responder JSON. Ej: ````json\n{...}\n```` → `{...}`
- **Algoritmo:**
1. Strip whitespace
2. Remover prefijo: `re.sub(r'^```(?:json)?\s*\n?', '', text, flags=re.IGNORECASE)`
3. Remover sufijo: `re.sub(r'\n?```\s*$', '', text)`
4. Strip final
- **Deps:** `re`
- **Tests:** JSON sin codeblock, JSON con ```json wrapper, JSON con ``` solo, texto que no es JSON
### 3. parse_llm_json
- **Dominio:** core
- **Lang:** Python
- **Purity:** pure
- **Signature:** `parse_llm_json(response: str) -> dict`
- **Descripcion:** Parsea una respuesta de LLM como JSON, limpiando primero think tags y markdown codeblocks. Combina strip_think_tags + strip_markdown_codeblock + json.loads.
- **Algoritmo:**
1. `strip_think_tags(response)`
2. `strip_markdown_codeblock(result)`
3. `json.loads(result)`
4. Si falla: raise ValueError con el texto limpiado para debugging
- **Deps:** `json`, `re`
- **Error type:** ValueError (JSON invalido)
- **Tests:** JSON limpio, JSON con think tags y codeblock, JSON invalido (error)
@@ -0,0 +1,33 @@
# Setup Logger (file rotation + console)
Fuente conceptual: MiroFish `backend/app/utils/logger.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funcion a implementar
### setup_logger
- **Dominio:** infra
- **Lang:** Python
- **Purity:** impure (crea archivos, modifica estado global de logging)
- **Signature:** `setup_logger(name: str = "app", log_dir: str = "logs", level: int = logging.DEBUG) -> logging.Logger`
- **Descripcion:** Configura un logger con dual output: archivo con rotacion diaria (detallado, DEBUG+) y consola (simple, INFO+). Maneja encoding UTF-8 en Windows.
- **Algoritmo:**
1. Crear log_dir si no existe
2. Crear logger con `logging.getLogger(name)`
3. `propagate = False` (evitar duplicados en root logger)
4. Si ya tiene handlers: retornar (idempotente)
5. Formato detallado (archivo): `[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s`
6. Formato simple (consola): `[%(asctime)s] %(levelname)s: %(message)s`
7. File handler: `RotatingFileHandler` con nombre `YYYY-MM-DD.log`, maxBytes=10MB, backupCount=5, encoding=utf-8
8. Console handler: `StreamHandler(sys.stdout)`, level=INFO
9. En Windows: `sys.stdout.reconfigure(encoding='utf-8', errors='replace')` para evitar mojibake
- **Deps:** `logging`, `logging.handlers`, `os`, `sys`, `datetime`
- **Tests:** logger se crea con 2 handlers, segundo call no duplica handlers, archivo se crea en log_dir
- **Companion:**
```python
def get_logger(name: str = "app") -> logging.Logger:
"""Get or create logger."""
logger = logging.getLogger(name)
return logger if logger.handlers else setup_logger(name)
```
@@ -0,0 +1,52 @@
# Retry Decorator (sync + async)
Fuente conceptual: MiroFish `backend/app/utils/retry.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
Nota: OpenViking spec 09 documenta retry como funciones. Este es complementario: retry como **decorador** con API mas ergonomica para decorar funciones existentes.
## Funciones a implementar
### 1. retry_with_backoff (decorator)
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (decorator que modifica comportamiento)
- **Signature:**
```python
def retry_with_backoff(
max_retries: int = 3,
initial_delay: float = 1.0,
max_delay: float = 30.0,
backoff_factor: float = 2.0,
jitter: bool = True,
exceptions: tuple[type[Exception], ...] = (Exception,),
on_retry: Callable[[Exception, int], None] | None = None
) -> Callable:
```
- **Descripcion:** Decorador que reintenta una funcion sincrona con exponential backoff cuando lanza excepciones del tipo especificado.
- **Uso:**
```python
@retry_with_backoff(max_retries=3, exceptions=(ConnectionError, TimeoutError))
def call_api():
...
```
- **Algoritmo:**
1. Wrapper con `functools.wraps`
2. Loop: intentar func(*args, **kwargs)
3. Si excepcion en `exceptions`:
- Si ultimo intento: re-raise
- Calcular delay: `min(delay, max_delay)`, si jitter: `*= (0.5 + random())`
- Llamar `on_retry(error, attempt)` si definido
- `time.sleep(delay)`, `delay *= backoff_factor`
- **Deps:** `functools`, `time`, `random`
### 2. retry_with_backoff_async (decorator)
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure
- **Signature:** Misma firma que `retry_with_backoff` pero retorna async wrapper
- **Descripcion:** Version async. Usa `asyncio.sleep` en vez de `time.sleep`.
- **Deps:** `asyncio`, `functools`, `random`
- **Tests:** funcion que falla 2 veces y luego exito (3 retries), funcion que siempre falla (agota retries y lanza), on_retry callback se llama, jitter produce delays variables, solo reintenta excepciones especificadas
@@ -0,0 +1,47 @@
# Cursor Paginator (generic)
Fuente conceptual: MiroFish `backend/app/utils/zep_paging.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funcion a implementar
### cursor_paginate
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (llama API externa)
- **Signature:**
```python
def cursor_paginate(
fetch_page: Callable[..., list[T]],
get_cursor: Callable[[T], str | None],
page_size: int = 100,
max_items: int = 2000,
max_retries: int = 3,
retry_delay: float = 2.0,
retryable_exceptions: tuple[type[Exception], ...] = (ConnectionError, TimeoutError, OSError),
) -> list[T]:
```
- **Descripcion:** Paginador generico basado en cursor que funciona con cualquier API que use cursor-based pagination. Cada pagina se obtiene con retry automatico. Se detiene cuando: pagina vacia, batch < page_size, o se alcanza max_items.
- **Algoritmo:**
1. `all_items = []`, `cursor = None`
2. Loop:
- Llamar `fetch_page(limit=page_size, cursor=cursor)` con retry (exponential backoff)
- Si batch vacio: break
- Extend all_items
- Si `len(all_items) >= max_items`: truncar y break
- Si `len(batch) < page_size`: break (ultima pagina)
- Obtener cursor del ultimo item: `cursor = get_cursor(batch[-1])`
- Si cursor es None: break
3. Retornar all_items
- **Patron:** Abstrae completamente el mecanismo de paginacion. El caller solo provee:
- `fetch_page`: funcion que recibe `limit` y `cursor` kwargs, retorna lista
- `get_cursor`: funcion que extrae el cursor del ultimo item
- **Deps:** `time`
- **Error type:** Exception (si todos los retries fallan en una pagina)
- **Tests:**
- API que retorna 3 paginas de 10 items
- API que falla 1 vez por pagina (retry funciona)
- max_items limita correctamente
- API que retorna pagina parcial (ultima pagina)
- Cursor None en ultimo item (se detiene)
@@ -0,0 +1,42 @@
# Simple i18n Translation
Fuente conceptual: MiroFish `backend/app/utils/locale.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funciones a implementar
### 1. t (translate)
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (lee estado thread-local)
- **Signature:** `t(key: str, locale: str | None = None, **kwargs) -> str`
- **Descripcion:** Traduce una clave con dot-path notation al idioma actual. Soporta interpolacion de variables con `{nombre}`.
- **Algoritmo:**
1. Determinar locale (parametro > thread-local > default)
2. Obtener diccionario de traducciones para ese locale
3. Navegar dot-path: `"report.taskStarted"``translations["report"]["taskStarted"]`
4. Si no existe: fallback al locale default
5. Si tampoco existe: retornar la key tal cual
6. Si hay kwargs: reemplazar `{key}``value` para cada kwarg
- **Ejemplo:**
```python
# translations = {"en": {"report": {"sectionStart": "Generating section: {title}"}}}
t("report.sectionStart", locale="en", title="Introduction")
# → "Generating section: Introduction"
```
- **Deps:** ninguna (stdlib)
- **Tests:** key existente, key inexistente (retorna key), interpolacion, dot-path profundo, fallback a locale default
### 2. load_translations
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (lee archivos)
- **Signature:** `load_translations(locales_dir: str) -> dict[str, dict]`
- **Descripcion:** Carga todos los archivos JSON de un directorio de locales. Cada archivo `{locale}.json` se carga como diccionario.
- **Algoritmo:**
1. Listar archivos `.json` en locales_dir
2. Para cada archivo: cargar JSON, key = nombre sin extension
3. Retornar dict de dicts
- **Deps:** `json`, `os`
@@ -0,0 +1,29 @@
# Extract Text from File (PDF/MD/TXT)
Fuente conceptual: MiroFish `backend/app/utils/file_parser.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funcion a implementar
### extract_text_from_file
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (I/O: lee archivos)
- **Signature:** `extract_text_from_file(file_path: str) -> str`
- **Descripcion:** Extrae texto plano de un archivo. Soporta PDF (PyMuPDF), Markdown y TXT (con deteccion automatica de encoding).
- **Algoritmo:**
1. Verificar que archivo existe
2. Determinar tipo por extension:
- `.pdf`: abrir con PyMuPDF (fitz), extraer texto de cada pagina, unir con `\n\n`
- `.md`, `.markdown`, `.txt`: leer con deteccion de encoding
3. Extension no soportada: raise ValueError
- **Deteccion de encoding (para texto):**
1. Intentar UTF-8
2. Si falla: `charset_normalizer.from_bytes(data).best().encoding`
3. Si falla: `chardet.detect(data)["encoding"]`
4. Ultimo recurso: UTF-8 con `errors='replace'`
- **Deps:** `PyMuPDF` (fitz), opcionalmente `charset_normalizer`, `chardet`
- **Error type:** FileNotFoundError, ValueError (extension no soportada), ImportError (PyMuPDF no instalado)
- **Diferencia con OpenViking:** OpenViking tiene parsers completos que convierten a markdown estructurado. Esta funcion es mas simple: solo extrae texto plano sin preservar estructura.
- **Tests:** PDF con texto, archivo MD UTF-8, archivo TXT latin-1, archivo inexistente (error), extension no soportada (error)
@@ -0,0 +1,60 @@
# ReACT Agent Loop
Fuente conceptual: MiroFish `backend/app/services/report_agent.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funcion a implementar
### react_loop
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (llama LLM y tools)
- **Signature:**
```python
def react_loop(
llm_chat: Callable[[list[dict]], str],
tools: dict[str, Callable[..., str]],
system_prompt: str,
user_prompt: str,
max_iterations: int = 5,
on_thought: Callable[[str], None] | None = None,
on_action: Callable[[str, dict], None] | None = None,
on_observation: Callable[[str], None] | None = None,
) -> str:
```
- **Descripcion:** Implementa el patron ReACT (Reasoning + Acting) para agentes LLM. El agente razona, decide usar herramientas, observa resultados, y repite hasta producir una respuesta final.
- **Algoritmo:**
1. Construir mensajes iniciales: system + user prompt con descripcion de tools disponibles
2. Loop (max_iterations):
- Llamar `llm_chat(messages)` → response
- Parsear response buscando patrones:
- `Thought:` → razonamiento del agente
- `Action:` → nombre de tool a llamar
- `Action Input:` → parametros (JSON)
- `Observation:` → (se llena con resultado del tool)
- `Final Answer:` → respuesta final, salir del loop
- Si hay Action:
- Llamar `tools[action_name](**action_input)`
- Anadir observacion a mensajes
- Callbacks: on_thought, on_action, on_observation
- Si hay Final Answer: retornar
3. Si se agota max_iterations: retornar ultimo response como fallback
- **Tools spec format:**
```
Available tools:
- search(query: str) -> str: Search knowledge graph
- analyze(entity_id: str) -> str: Deep dive into entity
```
- **Deps:** `json`, `re`
- **Error type:** Exception (tool falla, LLM timeout)
- **Notas:**
- El formato de parseo es flexible (Thought/Action/Observation es un patron comun en LangChain/ReACT)
- Los callbacks permiten logging/UI sin acoplar al agente
- La funcion es generica: funciona con cualquier LLM y cualquier set de tools
- **Tests:**
- Agente que usa 1 tool y da respuesta final
- Agente que usa multiples tools iterativamente
- Agente que da respuesta directa sin tools
- Max iterations alcanzado (fallback)
- Tool que falla (error handling)
@@ -0,0 +1,43 @@
# Batch Operations with Individual Retry
Fuente conceptual: MiroFish `backend/app/utils/retry.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Funcion a implementar
### call_batch_with_retry
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (ejecuta funciones, hace sleep)
- **Signature:**
```python
def call_batch_with_retry(
items: list[T],
process_func: Callable[[T], R],
max_retries: int = 3,
initial_delay: float = 1.0,
max_delay: float = 30.0,
backoff_factor: float = 2.0,
exceptions: tuple[type[Exception], ...] = (Exception,),
continue_on_failure: bool = True,
) -> tuple[list[R], list[dict]]:
```
- **Retorno:** `(results, failures)` donde failures es `[{"index": int, "item": T, "error": str}]`
- **Descripcion:** Procesa una lista de items con retry individual por item. Si un item falla despues de todos los retries, se registra como failure y se continua con el siguiente (o se detiene, segun `continue_on_failure`).
- **Algoritmo:**
1. Para cada (index, item) en items:
- Intentar `process_func(item)` con retry (exponential backoff)
- Si exito: append a results
- Si falla despues de todos los retries:
- Append a failures con index, item, error
- Si `continue_on_failure=False`: re-raise
2. Retornar (results, failures)
- **Diferencia con retry simple:** El retry simple reintenta una sola llamada. Este maneja **listas** donde cada item se reintenta independientemente, y los fallos individuales no bloquean el resto del batch.
- **Deps:** `time`, `random`
- **Tests:**
- Todos los items exito
- 1 item falla permanentemente, rest exito (continue_on_failure=True)
- 1 item falla, abort (continue_on_failure=False)
- Item que falla 2 veces y luego exito (retry funciona)
- failures contiene index correcto
@@ -0,0 +1,66 @@
# Tipo: Task / TaskStatus / TaskManager
Fuente conceptual: MiroFish `backend/app/models/task.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Tipos a implementar
### 1. TaskStatus (enum/sum)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** sum
- **Definicion:**
```python
class TaskStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
```
- **Descripcion:** Estado de una tarea de larga duracion (graph building, simulation, report generation).
### 2. Task (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class Task:
task_id: str # UUID
task_type: str # tipo libre: "graph_build", "simulation", etc.
status: TaskStatus
created_at: datetime
updated_at: datetime
progress: int = 0 # 0-100%
message: str = "" # mensaje de estado legible
result: dict | None = None # resultado al completar
error: str | None = None # error al fallar
metadata: dict = field(default_factory=dict)
progress_detail: dict = field(default_factory=dict) # detalles de progreso (etapa actual, items procesados, etc.)
```
- **Metodos:**
- `to_dict() -> dict` — serializacion con ISO timestamps
### 3. TaskManager (singleton thread-safe)
- **Dominio:** core
- **Lang:** Python
- **Purity:** impure (estado mutable, thread-safe)
- **Descripcion:** Gestor de tareas en memoria con thread safety. Patron singleton para acceso global. Util para tracking de tareas background con polling desde frontend.
- **Signature:**
```python
class TaskManager:
def create_task(self, task_type: str, metadata: dict | None = None) -> str: ...
def get_task(self, task_id: str) -> Task | None: ...
def update_task(self, task_id: str, status=None, progress=None, message=None, result=None, error=None, progress_detail=None) -> None: ...
def complete_task(self, task_id: str, result: dict) -> None: ...
def fail_task(self, task_id: str, error: str) -> None: ...
def list_tasks(self, task_type: str | None = None) -> list[dict]: ...
def cleanup_old_tasks(self, max_age_hours: int = 24) -> None: ...
```
- **Thread safety:** `threading.Lock` protege `_tasks: dict[str, Task]`
- **Singleton:** `__new__` con double-checked locking
- **Uso:** Background thread actualiza progreso via `update_task()`, frontend poll via `get_task()`. Pattern comun en web apps con tareas async.
@@ -0,0 +1,73 @@
# Tipos: AgentAction / RoundSummary / RunnerStatus
Fuente conceptual: MiroFish `backend/app/services/simulation_runner.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Tipos a implementar
### 1. RunnerStatus (enum/sum)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** sum
- **Definicion:**
```python
class RunnerStatus(str, Enum):
IDLE = "idle"
STARTING = "starting"
RUNNING = "running"
PAUSED = "paused"
STOPPING = "stopping"
STOPPED = "stopped"
COMPLETED = "completed"
FAILED = "failed"
```
- **Descripcion:** Maquina de estados para un runner de simulacion o pipeline de larga duracion. Transiciones validas:
- IDLE → STARTING → RUNNING
- RUNNING → PAUSED → RUNNING (resume)
- RUNNING → STOPPING → STOPPED
- RUNNING → COMPLETED
- RUNNING → FAILED
- STARTING → FAILED
### 2. AgentAction (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class AgentAction:
round_num: int # ronda de la simulacion
timestamp: str # ISO timestamp
platform: str # "twitter" | "reddit" | custom
agent_id: int # ID del agente
agent_name: str # nombre del agente
action_type: str # "CREATE_POST", "LIKE_POST", "COMMENT", etc.
action_args: dict = field(default_factory=dict) # parametros de la accion
result: str | None = None # resultado de la accion
success: bool = True # si la accion fue exitosa
```
- **Metodos:** `to_dict() -> dict`
- **Descripcion:** Registro de una accion individual de un agente en una simulacion multi-agente. Captura que hizo, cuando, en que plataforma, y si fue exitoso.
### 3. RoundSummary (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class RoundSummary:
round_num: int
start_time: str
end_time: str | None = None
simulated_hour: float = 0.0
actions: list[AgentAction] = field(default_factory=list)
active_agents: int = 0
```
- **Metodos:** `to_dict() -> dict`
- **Descripcion:** Resumen de una ronda de simulacion. Agrupa las acciones de todos los agentes en esa ronda con tiempos y conteos.
- **Uso:** Permite replay de simulaciones ronda por ronda, visualizacion de timeline, y analisis post-simulacion.
@@ -0,0 +1,46 @@
# Tipos: EntityNode / FilteredEntities
Fuente conceptual: MiroFish `backend/app/services/zep_entity_reader.py` (AGPL-3.0)
Reimplementar desde cero. No copiar codigo.
## Tipos a implementar
### 1. EntityNode (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class EntityNode:
uuid: str # identificador unico
name: str # nombre de la entidad
labels: list[str] = field(default_factory=list) # tipos/etiquetas (ej: ["Person", "Professor"])
summary: str = "" # resumen textual
attributes: dict = field(default_factory=dict) # atributos adicionales
related_edges: list[dict] = field(default_factory=list) # relaciones
related_nodes: list[dict] = field(default_factory=list) # nodos conectados
```
- **Metodos:**
- `to_dict() -> dict`
- `get_entity_type() -> str | None` — retorna el primer label que no sea generico ("Entity"), o None
- **Descripcion:** Nodo de entidad extraido de un knowledge graph. Contiene la identidad, tipo(s), atributos y relaciones de una entidad. Generico — no acoplado a Zep o ningun graph DB especifico.
### 2. FilteredEntities (product)
- **Dominio:** core
- **Lang:** Python
- **Algebraic:** product
- **Definicion:**
```python
@dataclass
class FilteredEntities:
entities: list[EntityNode]
entity_types: set[str] # tipos unicos encontrados
total_count: int # total antes de filtrar
filtered_count: int # total despues de filtrar
```
- **Metodos:** `to_dict() -> dict`
- **Descripcion:** Resultado de filtrar entidades de un graph por tipos definidos. Captura tanto las entidades filtradas como los conteos para reporting.
- **Uso:** Despues de construir un knowledge graph, se filtran las entidades por tipos definidos en la ontologia para obtener solo las relevantes. El total_count vs filtered_count muestra cuantas entidades se descartaron.
@@ -0,0 +1,49 @@
# Funciones para FuzzyGraph: Extraccion automatica de entidades y relaciones
Diseño original para `apps/fuzzygraph`. Pipeline completa:
```
documento → extract_text → preprocess → split_chunks
→ extract_entities_llm (por chunk) → deduplicate_entities
→ extract_relations_llm (por chunk + entities) → deduplicate_relations
→ insert operations.db
```
## Dependencias del registry existentes
| Funcion existente | ID | Para que |
|---|---|---|
| levenshtein_distance | `levenshtein_distance_py_cybersecurity` | Fuzzy matching de nombres |
| jaccard_similarity | `jaccard_similarity_py_cybersecurity` | Fuzzy matching de tokens |
| extract_urls | `extract_urls_py_cybersecurity` | Pre-extraer URLs como entidades Domain |
| normalize_url | `normalize_url_py_cybersecurity` | Normalizar URLs antes de dedup |
## Dependencias de specs pendientes (OpenViking/MiroFish)
| Spec | Para que |
|---|---|
| mf_09 extract_text_from_file | Sacar texto de PDF/MD/TXT |
| mf_01 split_text_into_chunks | Chunks con overlap |
| mf_02 preprocess_text | Normalizar whitespace |
| mf_04 parse_llm_json | Limpiar JSON del LLM |
| mf_06 retry_with_backoff | Reintentar llamadas LLM |
| mf_11 call_batch_with_retry | Procesar chunks en batch |
## Funciones nuevas (este directorio)
| # | Archivo | Dominio | Funcion |
|---|---------|---------|---------|
| 01 | fg_extract_entities_llm.md | datascience | extract_entities_llm |
| 02 | fg_extract_relations_llm.md | datascience | extract_relations_llm |
| 03 | fg_deduplicate_entities.md | datascience | deduplicate_entities |
| 04 | fg_deduplicate_relations.md | datascience | deduplicate_relations |
| 05 | fg_build_entity_schema_prompt.md | datascience | build_entity_schema_prompt, build_relation_schema_prompt |
| 06 | fg_normalize_entity_name.md | core | normalize_entity_name |
| 07 | fg_merge_entity_attributes.md | core | merge_entity_attributes |
| 08 | fg_extraction_pipeline.md | pipelines | extraction_pipeline (orquestador completo) |
## Tipos nuevos
| # | Archivo | Dominio | Tipos |
|---|---------|---------|-------|
| 09 | fg_type_extraction.md | datascience | EntityCandidate, RelationCandidate, ExtractionResult, DeduplicationResult |
@@ -0,0 +1,32 @@
# Funciones a implementar desde MiroFish
Fuente: `sources/MiroFish` (ByteDance)
Licencia: **AGPL-3.0** (NO permisiva)
Reimplementar desde cero. No copiar codigo.
MiroFish es un motor de inteligencia de enjambre para simulaciones predictivas:
knowledge graphs desde documentos, simulacion multi-agente en redes sociales, reportes analiticos.
## Funciones
| # | Archivo | Dominio | Funciones |
|---|---------|---------|-----------|
| 01 | mf_text_chunker.md | core | split_text_into_chunks (sentence-boundary aware, con overlap) |
| 02 | mf_preprocess_text.md | core | preprocess_text, get_text_stats |
| 03 | mf_to_pascal_case.md | core | to_pascal_case |
| 04 | mf_llm_client.md | core | strip_think_tags, strip_markdown_codeblock, chat_json_clean |
| 05 | mf_setup_logger.md | infra | setup_logger (file rotation + console + UTF-8) |
| 06 | mf_retry_decorator.md | core | retry_with_backoff (decorator), retry_with_backoff_async |
| 07 | mf_cursor_paginator.md | core | cursor_paginate (generic cursor-based pagination with retry) |
| 08 | mf_locale.md | core | t (i18n translate with dot-path keys + interpolation) |
| 09 | mf_extract_text.md | core | extract_text_from_file (PDF/MD/TXT con encoding detection) |
| 10 | mf_react_agent.md | core | react_loop (patron ReACT: Thought → Action → Observation) |
| 11 | mf_batch_retry.md | core | call_batch_with_retry (batch operations con retry individual) |
## Tipos
| # | Archivo | Dominio | Tipos |
|---|---------|---------|-------|
| 12 | mf_type_task.md | core | TaskStatus (sum), Task (product), TaskManager (singleton thread-safe) |
| 13 | mf_type_agent_action.md | core | AgentAction (product), RoundSummary (product), RunnerStatus (sum) |
| 14 | mf_type_entity_node.md | core | EntityNode (product), FilteredEntities (product) |
@@ -0,0 +1,27 @@
# jupyter_write: crear notebooks nuevos
**Componente:** `python/functions/notebook/jupyter_write.py`
## Problema
`jupyter_write.py append-*` falla con error websocket 4404 si el notebook no existe todavía. El modo colaborativo no puede inicializar un documento que no existe en disco.
Actualmente hay que crear el .ipynb manualmente como archivo antes de poder usar las funciones jupyter.
## Solución propuesta
Añadir subcomando `create` a `jupyter_write.py`:
```bash
$PYTHON jupyter_write.py create notebooks/01_foo.ipynb
$PYTHON jupyter_write.py create notebooks/01_foo.ipynb --kernel python3
```
Comportamiento:
1. Crear el archivo .ipynb con estructura mínima válida (nbformat 4, kernel metadata)
2. Opcionalmente aceptar celdas iniciales via stdin o argumentos
3. Si el notebook ya existe, no sobreescribir (error o flag `--force`)
## Contexto
Encontrado al intentar crear `01_matching_engine_fifo.ipynb` en `analysis/estudio_mercados`. Tuve que escribir el archivo directamente con Write en vez de usar las funciones del registry.
@@ -0,0 +1,20 @@
# jupyter_discover: detectar root_dir correctamente
**Componente:** `python/functions/notebook/jupyter_discover.py`
## Problema
`jupyter_discover.py` reporta el campo `analysis` incorrecto. Con Jupyter corriendo desde `analysis/estudio_mercados/`, el discover devolvió `"analysis": "estudio_embeddings"`.
El proceso real tenía `--ServerApp.root_dir=/home/lucas/fn_registry/analysis/estudio_mercados` en su cmdline, pero el discover no lo parsea.
## Solución propuesta
Mejorar la detección del analysis:
1. Parsear `--ServerApp.root_dir` de la cmdline del proceso (via `/proc/{pid}/cmdline` o `psutil`)
2. Alternativamente, consultar `GET /api/contents` que refleja el root_dir real
3. Fallback al cwd del proceso si no hay root_dir explícito
## Contexto
Encontrado al verificar que Jupyter de estudio_mercados estaba activo. El discover decía estudio_embeddings, lo cual era confuso y requirió verificación manual con `ss` y `/proc`.
@@ -0,0 +1,29 @@
# Documentación consolidada de herramientas Jupyter
**Componente:** `python/functions/notebook/`
## Problema
Las 5 funciones jupyter (`discover`, `read`, `exec`, `write`, `kernel`) tienen su documentación distribuida entre:
- El `--help` de cada script
- La sección de `CLAUDE.md` del proyecto raíz
- Los `.md` de frontmatter de cada función
No hay un documento único que explique el flujo completo, casos de uso comunes, y troubleshooting. Esto dificulta tanto el uso por agentes como por humanos.
## Solución propuesta
Crear una función de tipo `documentation` o un .md dedicado que incluya:
1. **Flujo típico**: discover → read → write/exec (con ejemplo completo)
2. **Tabla de subcomandos** por función con parámetros y ejemplos
3. **Troubleshooting**: errores comunes y soluciones
- Websocket 4404 (notebook no existe)
- Kernel does not exist (sesión stale)
- Document not yet synced (timing de colaboración)
4. **Limitaciones conocidas**: qué NO pueden hacer las funciones
5. **Diferencias con MCP jupyter**: por qué estas funciones lo reemplazan
## Ubicación sugerida
`python/functions/notebook/README.md` o bien una función `jupyter_help_py_notebook` que imprima la guía.
@@ -0,0 +1,20 @@
# jupyter_discover: soporte para múltiples instancias
**Componente:** `python/functions/notebook/jupyter_discover.py`
## Problema
Con varios análisis corriendo en puertos distintos (8888, 8889, etc.), el discover necesita:
- Listar todas las instancias con su root_dir correcto (ver issue 002)
- Indicar claramente qué análisis corresponde a qué puerto
- Facilitar la selección de instancia para las demás funciones
## Solución propuesta
1. Escanear puertos comunes (8888-8899) o parsear procesos jupyter activos
2. Devolver lista con: puerto, root_dir, analysis name, collaborative mode, kernels activos
3. Añadir flag `--port` a todas las funciones jupyter para apuntar a instancia específica
## Contexto
Actualmente si hay dos Jupyter corriendo hay que pasar `--port` manualmente y no queda claro cuál es cuál.
@@ -0,0 +1,29 @@
# jupyter_write: crear múltiples celdas en batch
**Componente:** `python/functions/notebook/jupyter_write.py`
## Problema
Para crear un notebook con contenido sustancial (como el matching engine con 12 celdas), hay que llamar `jupyter_write.py append-*` una vez por celda. Esto es lento por el overhead websocket+sync en cada llamada.
## Solución propuesta
Añadir subcomando `batch` que acepte un JSON/YAML con múltiples celdas:
```bash
$PYTHON jupyter_write.py batch notebooks/01_foo.ipynb --from cells.json
```
Donde `cells.json`:
```json
[
{"type": "markdown", "source": "# Título"},
{"type": "code", "source": "import pandas as pd"},
{"type": "markdown", "source": "## Sección 2"},
{"type": "code", "source": "df = pd.read_csv('data.csv')"}
]
```
Una sola conexión websocket, todas las celdas de golpe, una sola espera de sync.
Combinado con issue 001 (create), permitiría crear notebooks completos en una sola operación.
@@ -0,0 +1,28 @@
# jupyter_exec cell: KeyError 'outputs' en notebooks creados manualmente
**Componente:** `python/functions/notebook/jupyter_exec.py`
## Problema
Al ejecutar `jupyter_exec.py cell` sobre un notebook creado como archivo (no via Jupyter UI), falla con:
```
{"error": "'outputs'"}
```
La causa es que las celdas de código creadas manualmente pueden no incluir el campo `"outputs": []` o `"execution_count": null` que Jupyter espera. El exec no valida/normaliza la estructura antes de operar.
## Reproducción
1. Crear un .ipynb manualmente con celdas `"cell_type": "code"` sin campo `"outputs"`
2. Abrir en Jupyter Lab
3. Ejecutar `jupyter_exec.py cell notebook.ipynb <N>`
4. Error: `{"error": "'outputs'"}`
## Solución propuesta
En `jupyter_exec.py`, antes de ejecutar una celda, normalizar campos faltantes:
- Si `cell_type == "code"` y no tiene `outputs` → añadir `"outputs": []`
- Si no tiene `execution_count` → añadir `"execution_count": null`
Esto es especialmente relevante combinado con la issue 001 (crear notebooks) y 005 (batch), ya que los notebooks creados programáticamente son los más propensos a este problema.
+1 -1
View File
@@ -73,7 +73,7 @@ DataTable<T>(props: { data: T[]; columns: ColumnDef<T>[]; onRowClick?: (row: T)
| `kind: pipeline` | `purity` siempre `impure`. `uses_functions` no puede estar vacío. |
| `purity: pure` | `returns_optional` siempre `false`. `error_type` vacío. Una pura que devuelve opcional debe modelarse como tipo suma, no como `returns_optional: true`. |
| `purity: impure` | `error_type` obligatorio. Toda impura declara explícitamente qué puede salir mal. |
| `tested: true` | `test_file_path` obligatorio. `tests` no puede estar vacío. |
| `tested: true` | `test_file_path` obligatorio. `tests` no puede estar vacío. `fn index` extrae los test cases a la tabla `unit_tests` (ver [testing.md](testing.md)). |
| `tested: false` | `tests` vacío. `test_file_path` vacío. |
| `uses_functions[]` | Todos los IDs deben existir en la tabla `functions`. Sin referencias huérfanas. |
| `uses_types[]` | Todos los IDs deben existir en la tabla `types`. Sin referencias huérfanas. |
+247
View File
@@ -0,0 +1,247 @@
# Testing
El registry tiene dos niveles de tests:
- **Unit tests** (`unit_tests` en `registry.db`) — tests individuales extraidos automaticamente de los archivos de test de cada funcion.
- **E2E tests** (`e2e_tests` en `operations.db`) — tests de integracion que verifican como las funciones se componen dentro de una app.
---
## Unit tests
`fn index` lee cada archivo de test referenciado por `test_file_path` en las funciones testeadas, extrae los test cases individuales con su codigo, y los inserta en la tabla `unit_tests`.
### Tabla `unit_tests`
| Campo | Tipo | Descripcion |
|---|---|---|
| `id` | string | `{function_id}_t{n}` (ej: `filter_slice_go_core_t0`) |
| `function_id` | string | FK a `functions.id` |
| `name` | string | Nombre del test case |
| `code` | string | Codigo fuente completo del test |
| `file_path` | string | Ruta relativa al archivo de test |
| `lang` | string | Lenguaje (go, py, bash) |
| `created_at` | datetime | Fecha de indexacion |
| `updated_at` | datetime | Fecha de ultima indexacion |
FTS5 disponible sobre `id`, `name`, `code`, `function_id`, `lang`.
### Consultas utiles
```bash
# Todos los tests de una funcion
sqlite3 registry.db "SELECT id, name FROM unit_tests WHERE function_id = 'filter_slice_go_core';"
# Buscar tests por contenido (FTS5)
sqlite3 registry.db "SELECT id, function_id, name FROM unit_tests WHERE id IN (SELECT id FROM unit_tests_fts WHERE unit_tests_fts MATCH 'retry') LIMIT 10;"
# Tests por lenguaje
sqlite3 registry.db "SELECT lang, COUNT(*) FROM unit_tests GROUP BY lang;"
# Ver codigo de un test
sqlite3 registry.db "SELECT code FROM unit_tests WHERE id = 'cache_decorator_py_core_t0';"
```
---
## Convenciones de test por lenguaje
El parser automatico de `fn index` detecta test cases segun el lenguaje. Para que los tests se extraigan correctamente, seguir estas convenciones.
### Go
Convencion estandar de Go. El parser detecta funciones `func TestXxx(t *testing.T)`:
```go
func TestFilterSlice(t *testing.T) {
t.Run("filtra pares", func(t *testing.T) {
got := FilterSlice([]int{1, 2, 3, 4, 5}, func(n int) bool { return n%2 == 0 })
if len(got) != 2 || got[0] != 2 || got[1] != 4 {
t.Errorf("got %v, want [2 4]", got)
}
})
t.Run("slice vacio retorna vacio", func(t *testing.T) {
got := FilterSlice([]int{}, func(n int) bool { return true })
if len(got) != 0 {
t.Errorf("got %v, want []", got)
}
})
}
```
**Deteccion:** `^func (Test\w+)\s*\(` — cada `func Test...` es un test case. Los subtests (`t.Run`) se incluyen dentro del codigo del test padre.
**Archivo:** `{domain}/{name}_test.go` (convencion Go estandar).
### Python
Convencion estandar de pytest. El parser detecta funciones `def test_xxx(`:
```python
def test_funcion_llamada_una_vez(store):
calls = []
@cache_decorator(store, ttl=60)
def compute(x: int) -> int:
calls.append(x)
return x * 10
assert compute(5) == 50
assert compute(5) == 50
assert len(calls) == 1
def test_ttl_expirado(store):
# ...
```
**Deteccion:** `^def (test_\w+)\s*\(` — cada funcion top-level `def test_...` es un test case. El codigo incluye todo hasta la siguiente `def test_` o fin de archivo.
**Archivo:** `{domain}/{name}_test.py`.
### Bash
Bash no tiene framework estandar de testing. El parser soporta tres convenciones, en orden de prioridad:
#### Opcion 1: funciones `test_xxx()` (preferida)
La mas explicita y la que mejor se parsea:
```bash
#!/usr/bin/env bash
source "$(dirname "$0")/mi_funcion.sh"
PASS=0; FAIL=0
assert_eq() {
local name="$1" got="$2" want="$3"
if [ "$got" = "$want" ]; then echo " PASS: $name"; ((PASS++))
else echo " FAIL: $name (got='$got', want='$want')"; ((FAIL++)); fi
}
test_caso_basico() {
local got
got=$(mi_funcion "input")
assert_eq "caso basico" "$got" "expected"
}
test_caso_vacio() {
local got
got=$(mi_funcion "")
assert_eq "input vacio" "$got" ""
}
# Ejecutar todos los tests
test_caso_basico
test_caso_vacio
echo "Resultados: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1
```
**Deteccion:** `^(test_\w+)\s*\(\)\s*\{` — cada funcion `test_xxx() { ... }` es un test case.
#### Opcion 2: secciones `=== nombre ===`
Para tests que agrupan multiples asserts bajo secciones nombradas:
```bash
#!/usr/bin/env bash
source "$(dirname "$0")/mi_funcion.sh"
echo "=== caso basico ==="
got=$(mi_funcion "input")
assert_eq "retorna expected" "$got" "expected"
echo "=== caso edge ==="
got=$(mi_funcion "")
assert_eq "input vacio" "$got" ""
echo "=== errores ==="
assert_fail "input invalido" mi_funcion "--bad"
```
**Deteccion:** `^(echo\s+["'])?===\s*(\w[\w\s]*\w)\s*===(["'])?\s*$` — cada linea con `=== nombre ===` (con o sin `echo`) abre una seccion. El nombre debe contener al menos dos caracteres alfanumericos (las lineas de separacion puras como `======` se ignoran).
#### Opcion 3: comentarios `# Test:`
Para scripts simples donde cada test se marca con un comentario:
```bash
#!/usr/bin/env bash
source "$(dirname "$0")/mi_funcion.sh"
# Test: caso basico
got=$(mi_funcion "input")
[ "$got" = "expected" ] || { echo "FAIL"; exit 1; }
# Test: input vacio
got=$(mi_funcion "")
[ "$got" = "" ] || { echo "FAIL"; exit 1; }
```
**Deteccion:** `^#\s*[Tt]est:\s*(.+)` — cada comentario `# Test: nombre` abre un bloque.
#### Recomendacion
Usar **opcion 1** (funciones `test_xxx()`) para tests nuevos. Es la mas explicita, cada test esta aislado en su propia funcion, y se parsea sin ambiguedad.
La **opcion 2** (secciones `===`) es aceptable cuando ya existe el patron en el archivo (como `pass_test.sh`).
**Archivo:** `{domain}/{name}_test.sh`.
---
## E2E tests
Los e2e tests viven en `operations.db` de cada app. No se extraen automaticamente — se crean manualmente o por el bucle reactivo cuando se necesita verificar que un flujo end-to-end funciona.
### Tabla `e2e_tests`
| Campo | Tipo | Descripcion |
|---|---|---|
| `id` | string | Identificador unico |
| `name` | string | Nombre descriptivo del test |
| `description` | string | Que verifica este test |
| `relation_id` | string | FK a `relations.id` — que pipeline/relacion prueba |
| `steps` | []string | Funciones involucradas en orden |
| `input_fixture` | JSON | Datos de entrada para el test |
| `expected` | JSON | Resultado esperado |
| `last_status` | string | pass, fail, skip, o vacio |
| `last_run_at` | datetime | Ultima ejecucion |
| `execution_id` | string | Referencia a la ejecucion que lo corrio |
| `duration_ms` | int | Duracion en milisegundos |
| `created_at` | datetime | Fecha de creacion |
| `updated_at` | datetime | Ultima actualizacion |
FTS5 disponible sobre `id`, `name`, `description`, `steps`.
### Diferencia con assertions
| | Assertions | E2E tests |
|---|---|---|
| **Que son** | Reglas declarativas sobre datos | Ejecuciones concretas de flujos |
| **Cuando corren** | En cada ejecucion del bucle reactivo | Bajo demanda o en CI |
| **Sobre que** | Una entity (`precio > 0`) | Un flujo completo (input → pipeline → output) |
| **Resultado** | pass/fail sobre el valor actual | pass/fail comparando output vs expected |
### Ejemplo de uso
```bash
# Crear un e2e test para un pipeline
fn ops e2e add --name "metabase_setup_completo" \
--relation-id "rel_setup_metabase" \
--steps '["docker_pull_image_go_infra","init_metabase_go_pipelines"]' \
--input '{"project":"test"}' \
--expected '{"status":"running"}'
# Listar e2e tests
fn ops e2e list
# Ver resultado
fn ops e2e show <id>
```
@@ -0,0 +1,42 @@
-- e2e_tests: integration tests that verify function composition within an app
CREATE TABLE e2e_tests (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
relation_id TEXT DEFAULT '' REFERENCES relations(id),
steps TEXT NOT NULL DEFAULT '[]',
input_fixture TEXT NOT NULL DEFAULT '{}',
expected TEXT NOT NULL DEFAULT '{}',
last_status TEXT NOT NULL DEFAULT '',
last_run_at TEXT NOT NULL DEFAULT '',
execution_id TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_e2e_tests_relation ON e2e_tests(relation_id);
CREATE INDEX idx_e2e_tests_status ON e2e_tests(last_status);
CREATE VIRTUAL TABLE e2e_tests_fts USING fts5(
id, name, description, steps,
content='e2e_tests',
content_rowid='rowid'
);
CREATE TRIGGER e2e_tests_ai AFTER INSERT ON e2e_tests BEGIN
INSERT INTO e2e_tests_fts(rowid, id, name, description, steps)
VALUES (new.rowid, new.id, new.name, new.description, new.steps);
END;
CREATE TRIGGER e2e_tests_ad AFTER DELETE ON e2e_tests BEGIN
INSERT INTO e2e_tests_fts(e2e_tests_fts, rowid, id, name, description, steps)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.steps);
END;
CREATE TRIGGER e2e_tests_au AFTER UPDATE ON e2e_tests BEGIN
INSERT INTO e2e_tests_fts(e2e_tests_fts, rowid, id, name, description, steps)
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.steps);
INSERT INTO e2e_tests_fts(rowid, id, name, description, steps)
VALUES (new.rowid, new.id, new.name, new.description, new.steps);
END;
+27
View File
@@ -167,6 +167,33 @@ type Log struct {
CreatedAt time.Time `json:"created_at"`
}
// E2ETestStatus represents the result of an e2e test run.
type E2ETestStatus string
const (
E2EPass E2ETestStatus = "pass"
E2EFail E2ETestStatus = "fail"
E2ESkip E2ETestStatus = "skip"
E2EPending E2ETestStatus = ""
)
// E2ETest is an integration test that verifies function composition within an app.
type E2ETest struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
RelationID string `json:"relation_id"`
Steps []string `json:"steps"`
InputFixture map[string]any `json:"input_fixture"`
Expected map[string]any `json:"expected"`
LastStatus E2ETestStatus `json:"last_status"`
LastRunAt string `json:"last_run_at"`
ExecutionID string `json:"execution_id"`
DurationMs int64 `json:"duration_ms"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TypeSnapshot is an immutable copy of a registry type at point of use.
type TypeSnapshot struct {
ID string `json:"id"`
+119
View File
@@ -903,3 +903,122 @@ func (db *DB) ListLogs(level LogLevel, source, entityID, executionID string, lim
}
return result, nil
}
// --- E2E Tests CRUD ---
// InsertE2ETest inserts or replaces an e2e test.
func (db *DB) InsertE2ETest(t *E2ETest) error {
now := time.Now().UTC()
if t.CreatedAt.IsZero() {
t.CreatedAt = now
}
t.UpdatedAt = now
_, err := db.conn.Exec(`
INSERT OR REPLACE INTO e2e_tests (
id, name, description, relation_id, steps, input_fixture,
expected, last_status, last_run_at, execution_id, duration_ms,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Name, t.Description, t.RelationID,
marshalStrings(t.Steps), marshalJSON(t.InputFixture), marshalJSON(t.Expected),
string(t.LastStatus), t.LastRunAt, t.ExecutionID, t.DurationMs,
t.CreatedAt.Format(time.RFC3339), t.UpdatedAt.Format(time.RFC3339),
)
return err
}
// GetE2ETest returns an e2e test by ID.
func (db *DB) GetE2ETest(id string) (*E2ETest, error) {
row := db.conn.QueryRow(`
SELECT id, name, description, relation_id, steps, input_fixture,
expected, last_status, last_run_at, execution_id, duration_ms,
created_at, updated_at
FROM e2e_tests WHERE id = ?`, id)
t, err := scanE2ETest(row)
if err != nil {
return nil, fmt.Errorf("e2e test %q not found: %w", id, err)
}
return t, nil
}
// ListE2ETests returns e2e tests with optional status filter.
func (db *DB) ListE2ETests(status E2ETestStatus) ([]E2ETest, error) {
where := []string{}
args := []any{}
if status != "" {
where = append(where, "last_status = ?")
args = append(args, string(status))
}
q := `SELECT id, name, description, relation_id, steps, input_fixture,
expected, last_status, last_run_at, execution_id, duration_ms,
created_at, updated_at
FROM e2e_tests`
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
q += " ORDER BY name"
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []E2ETest
for rows.Next() {
var t E2ETest
var stepsJSON, fixtureJSON, expectedJSON, createdAt, updatedAt string
if err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.RelationID,
&stepsJSON, &fixtureJSON, &expectedJSON,
&t.LastStatus, &t.LastRunAt, &t.ExecutionID, &t.DurationMs,
&createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("scanning e2e test: %w", err)
}
t.Steps = unmarshalStrings(stepsJSON)
t.InputFixture = unmarshalJSON(fixtureJSON)
t.Expected = unmarshalJSON(expectedJSON)
t.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
result = append(result, t)
}
return result, nil
}
// UpdateE2ETestResult updates the result fields after running an e2e test.
func (db *DB) UpdateE2ETestResult(id string, status E2ETestStatus, executionID string, durationMs int64) error {
now := time.Now().UTC()
_, err := db.conn.Exec(`
UPDATE e2e_tests SET last_status=?, last_run_at=?, execution_id=?, duration_ms=?, updated_at=?
WHERE id=?`,
string(status), now.Format(time.RFC3339), executionID, durationMs,
now.Format(time.RFC3339), id,
)
return err
}
// DeleteE2ETest removes an e2e test by ID.
func (db *DB) DeleteE2ETest(id string) error {
_, err := db.conn.Exec("DELETE FROM e2e_tests WHERE id = ?", id)
return err
}
func scanE2ETest(row *sql.Row) (*E2ETest, error) {
var t E2ETest
var stepsJSON, fixtureJSON, expectedJSON, createdAt, updatedAt string
err := row.Scan(&t.ID, &t.Name, &t.Description, &t.RelationID,
&stepsJSON, &fixtureJSON, &expectedJSON,
&t.LastStatus, &t.LastRunAt, &t.ExecutionID, &t.DurationMs,
&createdAt, &updatedAt)
if err != nil {
return nil, err
}
t.Steps = unmarshalStrings(stepsJSON)
t.InputFixture = unmarshalJSON(fixtureJSON)
t.Expected = unmarshalJSON(expectedJSON)
t.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
return &t, nil
}
+34
View File
@@ -14,6 +14,7 @@ type IndexResult struct {
Types int
Apps int
Analysis int
UnitTests int
ValidationErrors []string
Warnings []string
Errors []string
@@ -203,6 +204,39 @@ func Index(db *DB, root string) (*IndexResult, error) {
result.Analysis++
}
// Extract unit tests from test files of tested functions
if err := db.PurgeUnitTests(); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("purging unit_tests: %v", err))
}
for _, f := range functions {
if !f.Tested || f.TestFilePath == "" {
continue
}
absTestPath := filepath.Join(root, f.TestFilePath)
cases, err := parseTestFile(absTestPath, f.Lang)
if err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("%s: parsing tests: %v", f.ID, err))
continue
}
for i, tc := range cases {
ut := &UnitTest{
ID: fmt.Sprintf("%s_t%d", f.ID, i),
FunctionID: f.ID,
Name: tc.Name,
Code: tc.Code,
FilePath: f.TestFilePath,
Lang: f.Lang,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.InsertUnitTest(ut); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("insert unit_test %s: %v", ut.ID, err))
continue
}
result.UnitTests++
}
}
// Post-insert: warn about file_path entries that don't exist on disk
for _, f := range functions {
if f.FilePath != "" {
+37
View File
@@ -0,0 +1,37 @@
-- unit_tests: individual test cases extracted from test files
CREATE TABLE unit_tests (
id TEXT PRIMARY KEY,
function_id TEXT NOT NULL REFERENCES functions(id) ON DELETE CASCADE,
name TEXT NOT NULL,
code TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL DEFAULT '',
lang TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_unit_tests_function ON unit_tests(function_id);
CREATE INDEX idx_unit_tests_lang ON unit_tests(lang);
CREATE VIRTUAL TABLE unit_tests_fts USING fts5(
id, name, code, function_id, lang,
content='unit_tests',
content_rowid='rowid'
);
CREATE TRIGGER unit_tests_ai AFTER INSERT ON unit_tests BEGIN
INSERT INTO unit_tests_fts(rowid, id, name, code, function_id, lang)
VALUES (new.rowid, new.id, new.name, new.code, new.function_id, new.lang);
END;
CREATE TRIGGER unit_tests_ad AFTER DELETE ON unit_tests BEGIN
INSERT INTO unit_tests_fts(unit_tests_fts, rowid, id, name, code, function_id, lang)
VALUES ('delete', old.rowid, old.id, old.name, old.code, old.function_id, old.lang);
END;
CREATE TRIGGER unit_tests_au AFTER UPDATE ON unit_tests BEGIN
INSERT INTO unit_tests_fts(unit_tests_fts, rowid, id, name, code, function_id, lang)
VALUES ('delete', old.rowid, old.id, old.name, old.code, old.function_id, old.lang);
INSERT INTO unit_tests_fts(rowid, id, name, code, function_id, lang)
VALUES (new.rowid, new.id, new.name, new.code, new.function_id, new.lang);
END;
+12
View File
@@ -180,6 +180,18 @@ type Proposal struct {
UpdatedAt time.Time `json:"updated_at"`
}
// UnitTest represents an individual test case extracted from a test file.
type UnitTest struct {
ID string `json:"id"`
FunctionID string `json:"function_id"`
Name string `json:"name"`
Code string `json:"code"`
FilePath string `json:"file_path"`
Lang string `json:"lang"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// GenerateID builds the canonical ID: {name}_{lang}_{domain}
func GenerateID(name, lang, domain string) string {
return name + "_" + lang + "_" + domain
+85
View File
@@ -614,6 +614,91 @@ func scanTypes(rows interface{ Next() bool; Scan(...any) error }) ([]Type, error
return result, nil
}
// --- Unit Tests CRUD ---
// InsertUnitTest inserts or replaces a unit test entry.
func (db *DB) InsertUnitTest(ut *UnitTest) error {
now := time.Now().UTC()
if ut.CreatedAt.IsZero() {
ut.CreatedAt = now
}
if ut.UpdatedAt.IsZero() {
ut.UpdatedAt = now
}
_, err := db.conn.Exec(`
INSERT OR REPLACE INTO unit_tests (
id, function_id, name, code, file_path, lang, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
ut.ID, ut.FunctionID, ut.Name, ut.Code, ut.FilePath, ut.Lang,
ut.CreatedAt.Format(time.RFC3339), ut.UpdatedAt.Format(time.RFC3339),
)
return err
}
// GetUnitTestsByFunction returns all unit tests for a given function ID.
func (db *DB) GetUnitTestsByFunction(functionID string) ([]UnitTest, error) {
rows, err := db.conn.Query(
"SELECT id, function_id, name, code, file_path, lang, created_at, updated_at FROM unit_tests WHERE function_id = ? ORDER BY name",
functionID,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanUnitTests(rows)
}
// SearchUnitTests performs FTS search on unit tests.
func (db *DB) SearchUnitTests(query string, lang string) ([]UnitTest, error) {
where := []string{}
args := []any{}
if query != "" {
where = append(where, "ut.id IN (SELECT id FROM unit_tests_fts WHERE unit_tests_fts MATCH ?)")
args = append(args, query)
}
if lang != "" {
where = append(where, "ut.lang = ?")
args = append(args, lang)
}
sql := "SELECT id, function_id, name, code, file_path, lang, created_at, updated_at FROM unit_tests ut"
if len(where) > 0 {
sql += " WHERE " + strings.Join(where, " AND ")
}
sql += " ORDER BY ut.function_id, ut.name"
rows, err := db.conn.Query(sql, args...)
if err != nil {
return nil, fmt.Errorf("search unit tests: %w", err)
}
defer rows.Close()
return scanUnitTests(rows)
}
func scanUnitTests(rows interface{ Next() bool; Scan(...any) error }) ([]UnitTest, error) {
var result []UnitTest
for rows.Next() {
var ut UnitTest
var createdAt, updatedAt string
err := rows.Scan(&ut.ID, &ut.FunctionID, &ut.Name, &ut.Code, &ut.FilePath, &ut.Lang, &createdAt, &updatedAt)
if err != nil {
return nil, fmt.Errorf("scanning unit test: %w", err)
}
ut.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
ut.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
result = append(result, ut)
}
return result, nil
}
// PurgeUnitTests deletes all unit test entries. Used before re-indexing.
func (db *DB) PurgeUnitTests() error {
_, err := db.conn.Exec("DELETE FROM unit_tests")
return err
}
// --- Proposal CRUD ---
// InsertProposal inserts or replaces a proposal.
+133
View File
@@ -0,0 +1,133 @@
package registry
import (
"fmt"
"os"
"regexp"
"strings"
)
// testCase represents a single test extracted from a test file.
type testCase struct {
Name string
Code string
}
// testPos marks the start of a test within a file.
type testPos struct {
name string
startLine int
}
// parseTestFile reads a test file and extracts individual test cases.
// Supports Go, Python, and Bash test formats.
func parseTestFile(path, lang string) ([]testCase, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading test file %s: %w", path, err)
}
content := string(data)
switch lang {
case "go":
return parseGoTests(content), nil
case "py":
return parsePythonTests(content), nil
case "bash":
return parseBashTests(content), nil
default:
return nil, nil
}
}
// parseGoTests extracts Go test functions (func TestXxx).
var goTestFuncRe = regexp.MustCompile(`(?m)^func\s+(Test\w+)\s*\(`)
func parseGoTests(content string) []testCase {
lines := strings.Split(content, "\n")
var positions []testPos
for i, line := range lines {
if m := goTestFuncRe.FindStringSubmatch(line); m != nil {
positions = append(positions, testPos{name: m[1], startLine: i})
}
}
return extractBlocks(lines, positions)
}
// parsePythonTests extracts Python test functions (def test_xxx).
var pyTestFuncRe = regexp.MustCompile(`(?m)^def\s+(test_\w+)\s*\(`)
func parsePythonTests(content string) []testCase {
lines := strings.Split(content, "\n")
var positions []testPos
for i, line := range lines {
if m := pyTestFuncRe.FindStringSubmatch(line); m != nil {
positions = append(positions, testPos{name: m[1], startLine: i})
}
}
return extractBlocks(lines, positions)
}
// parseBashTests extracts Bash test blocks.
// Tries three conventions in order:
// 1. test_xxx() { ... } — function-based tests
// 2. === section === — section headers (echo "=== name ===")
// 3. # Test: ... — comment-based test blocks
var bashTestFuncRe = regexp.MustCompile(`(?m)^(test_\w+)\s*\(\)\s*\{`)
var bashTestCommentRe = regexp.MustCompile(`(?m)^#\s*[Tt]est:\s*(.+)`)
var bashSectionRe = regexp.MustCompile(`(?i)^(?:echo\s+["'])?===\s*(\w[\w\s]*\w)\s*===["']?\s*$`)
func parseBashTests(content string) []testCase {
lines := strings.Split(content, "\n")
// Strategy 1: test_xxx() { ... }
var positions []testPos
for i, line := range lines {
if m := bashTestFuncRe.FindStringSubmatch(line); m != nil {
positions = append(positions, testPos{name: m[1], startLine: i})
}
}
if len(positions) > 0 {
return extractBlocks(lines, positions)
}
// Strategy 2: === section === headers
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if m := bashSectionRe.FindStringSubmatch(trimmed); m != nil {
positions = append(positions, testPos{name: m[1], startLine: i})
}
}
if len(positions) > 0 {
return extractBlocks(lines, positions)
}
// Strategy 3: # Test: ... comments
for i, line := range lines {
if m := bashTestCommentRe.FindStringSubmatch(line); m != nil {
positions = append(positions, testPos{name: strings.TrimSpace(m[1]), startLine: i})
}
}
return extractBlocks(lines, positions)
}
// extractBlocks splits lines into code blocks based on test positions.
func extractBlocks(lines []string, positions []testPos) []testCase {
var tests []testCase
for i, pos := range positions {
endLine := len(lines)
if i+1 < len(positions) {
endLine = positions[i+1].startLine
}
for endLine > pos.startLine && strings.TrimSpace(lines[endLine-1]) == "" {
endLine--
}
code := strings.Join(lines[pos.startLine:endLine], "\n")
tests = append(tests, testCase{Name: pos.name, Code: code})
}
return tests
}