auto(0129): agents_dashboard — secret_store_cpp_infra + CMakeLists register #4

Open
dataforge wants to merge 615 commits from auto/0129 into master
110 changed files with 5714 additions and 0 deletions
Showing only changes of commit 9fd0ca9cac - Show all commits
+6
View File
@@ -0,0 +1,6 @@
from .setup_logger import setup_logger, get_logger
__all__ = [
"setup_logger",
"get_logger",
]
+60
View File
@@ -0,0 +1,60 @@
---
name: cache_to_file
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def cache_to_file(cache_dir: str, namespace: str = 'default') -> FileCache"
description: "Cache key-value donde cada entry es un archivo JSON en disco. Keys se hashean con SHA-256 para generar nombres de archivo seguros. Metadata (ttl, created_at, original_key) en sidecar .meta. Mejor que SQLite para valores grandes (PDFs procesados, embeddings)."
tags: [cache, file, persistence, ttl, key-value, sha256]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["os", "json", "hashlib", "time", "threading"]
tested: true
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"
test_file_path: "python/functions/infra/cache_to_file_test.py"
file_path: "python/functions/infra/cache_to_file.py"
---
## Ejemplo
```python
from infra.cache_to_file import cache_to_file
store = cache_to_file("/tmp/my_cache", namespace="embeddings")
# Almacenar un embedding grande
store.set("doc:123", embedding_vector, ttl=86400)
# Recuperar
vec = store.get("doc:123")
# Factory pattern
result = store.get_or_set(
"pdf:page_42",
factory=lambda: extract_pdf_text("doc.pdf", page=42),
ttl=0, # sin expiracion
)
```
## Estructura en disco
```
cache_dir/
namespace/
{sha256_key}.json # valor serializado como JSON
{sha256_key}.meta # {"created_at": ..., "expires_at": ..., "original_key": ...}
```
## Notas
Cada entry genera exactamente dos archivos: `.json` para el valor y `.meta` para la metadata. La key original se guarda en `.meta["original_key"]` para facilitar debugging. Thread-safe mediante `threading.Lock`. La eviction es lazy: se verifica expires_at al hacer `get`.
+135
View File
@@ -0,0 +1,135 @@
"""Cache key-value donde cada entry es un archivo JSON en disco."""
import hashlib
import json
import os
import threading
import time
class FileCache:
"""Cache key-value respaldado en archivos JSON, con metadata sidecar .meta."""
def __init__(self, cache_dir: str, namespace: str = "default") -> None:
self._base = os.path.join(cache_dir, namespace)
self._hits = 0
self._misses = 0
self._lock = threading.Lock()
os.makedirs(self._base, exist_ok=True)
def _hash_key(self, key: str) -> str:
return hashlib.sha256(key.encode("utf-8")).hexdigest()
def _value_path(self, hashed: str) -> str:
return os.path.join(self._base, f"{hashed}.json")
def _meta_path(self, hashed: str) -> str:
return os.path.join(self._base, f"{hashed}.meta")
def _is_expired(self, meta: dict) -> bool:
expires_at = meta.get("expires_at")
if expires_at is None:
return False
return time.time() >= expires_at
def _load_meta(self, hashed: str) -> dict | None:
path = self._meta_path(hashed)
if not os.path.exists(path):
return None
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def get(self, key: str) -> object:
"""Retorna el valor o None si no existe o esta expirado."""
hashed = self._hash_key(key)
with self._lock:
meta = self._load_meta(hashed)
if meta is None:
self._misses += 1
return None
if self._is_expired(meta):
self._delete_files(hashed)
self._misses += 1
return None
value_path = self._value_path(hashed)
if not os.path.exists(value_path):
self._misses += 1
return None
with open(value_path, "r", encoding="utf-8") as f:
self._hits += 1
return json.load(f)
def set(self, key: str, value: object, ttl: float = 0) -> None:
"""Almacena un valor. ttl en segundos; 0 = sin expiracion."""
hashed = self._hash_key(key)
now = time.time()
expires_at = (now + ttl) if ttl > 0 else None
meta = {"created_at": now, "expires_at": expires_at, "original_key": key}
with self._lock:
with open(self._value_path(hashed), "w", encoding="utf-8") as f:
json.dump(value, f)
with open(self._meta_path(hashed), "w", encoding="utf-8") as f:
json.dump(meta, f)
def _delete_files(self, hashed: str) -> bool:
vp = self._value_path(hashed)
mp = self._meta_path(hashed)
deleted = False
if os.path.exists(vp):
os.remove(vp)
deleted = True
if os.path.exists(mp):
os.remove(mp)
deleted = True
return deleted
def delete(self, key: str) -> bool:
"""Elimina una entrada. Retorna True si existia."""
hashed = self._hash_key(key)
with self._lock:
return self._delete_files(hashed)
def clear(self) -> int:
"""Elimina todas las entradas del namespace. Retorna pares eliminados."""
with self._lock:
count = 0
if not os.path.isdir(self._base):
return 0
for fname in os.listdir(self._base):
if fname.endswith(".json"):
count += 1
fpath = os.path.join(self._base, fname)
os.remove(fpath)
return count
def stats(self) -> dict:
"""Retorna estadisticas del store: hits, misses y size actual."""
with self._lock:
if not os.path.isdir(self._base):
size = 0
else:
size = sum(
1 for f in os.listdir(self._base) if f.endswith(".json")
)
return {"hits": self._hits, "misses": self._misses, "size": size}
def get_or_set(self, key: str, factory: callable, ttl: float = 0) -> object:
"""Retorna el valor cacheado o llama factory() y lo almacena."""
value = self.get(key)
if value is None:
value = factory()
self.set(key, value, ttl)
return value
def cache_to_file(cache_dir: str, namespace: str = "default") -> FileCache:
"""Crea un FileCache respaldado en archivos JSON en disco.
Args:
cache_dir: Directorio raiz donde se almacenan los archivos de cache.
namespace: Subdirectorio logico dentro de cache_dir.
Returns:
FileCache con metodos get/set/delete/clear/stats/get_or_set.
"""
return FileCache(cache_dir, namespace)
@@ -0,0 +1,54 @@
"""Tests para cache_to_file."""
import json
import os
import time
import pytest
from .cache_to_file import cache_to_file
@pytest.fixture
def store(tmp_path):
return cache_to_file(str(tmp_path))
def test_set_y_get_basico(store):
store.set("hello", {"x": 42})
assert store.get("hello") == {"x": 42}
def test_ttl_expirado_retorna_none(store):
store.set("temp", "val", ttl=0.05)
time.sleep(0.1)
assert store.get("temp") is None
def test_archivo_meta_con_metadata_correcta(tmp_path):
s = cache_to_file(str(tmp_path), "ns")
s.set("mykey", "myval", ttl=60)
ns_dir = os.path.join(str(tmp_path), "ns")
meta_files = [f for f in os.listdir(ns_dir) if f.endswith(".meta")]
assert len(meta_files) == 1
with open(os.path.join(ns_dir, meta_files[0])) as f:
meta = json.load(f)
assert meta["original_key"] == "mykey"
assert meta["expires_at"] is not None
assert meta["created_at"] > 0
def test_clear_elimina_directorio_del_namespace(tmp_path):
s = cache_to_file(str(tmp_path), "mynamespace")
s.set("a", 1)
s.set("b", 2)
removed = s.clear()
assert removed == 2
assert s.get("a") is None
assert s.get("b") is None
def test_key_con_caracteres_especiales_hash_seguro(store):
key = "https://example.com/path?q=1&r=2 <special>#hash"
store.set(key, "safe")
assert store.get(key) == "safe"
+57
View File
@@ -0,0 +1,57 @@
---
name: cache_to_sqlite
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def cache_to_sqlite(db_path: str, namespace: str = 'default') -> CacheStore"
description: "Cache key-value persistido en SQLite con TTL y lazy eviction. Cada namespace es un espacio logico dentro de la misma BD. Keys son strings, values se serializan con JSON. TTL en segundos, 0 = sin expiracion. Thread-safe mediante mutex."
tags: [cache, sqlite, persistence, ttl, memoize, key-value]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["sqlite3", "json", "time", "threading"]
tested: true
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)"
test_file_path: "python/functions/infra/cache_to_sqlite_test.py"
file_path: "python/functions/infra/cache_to_sqlite.py"
---
## Ejemplo
```python
from infra.cache_to_sqlite import cache_to_sqlite
store = cache_to_sqlite("my_cache.db", namespace="llm")
# Almacenar con TTL de 1 hora
store.set("prompt:explain_x", "explanation...", ttl=3600)
# Recuperar (None si miss o expirado)
val = store.get("prompt:explain_x")
# Factory pattern: solo computa si no esta en cache
result = store.get_or_set(
"prompt:explain_y",
factory=lambda: call_llm("explain y"),
ttl=3600,
)
# Estadisticas
print(store.stats()) # {"hits": 2, "misses": 1, "size": 5}
```
## Notas
La eviction de entradas expiradas es lazy: se ejecuta en cada llamada a `get` o `stats`, no en background. El schema SQLite usa `(namespace, key)` como PRIMARY KEY para garantizar upserts atomicos. Usa WAL mode para mejor concurrencia de lecturas. Cada thread mantiene su propia conexion SQLite (thread-local), sincronizada via `threading.Lock` para escrituras.
+142
View File
@@ -0,0 +1,142 @@
"""Cache key-value persistido en SQLite con TTL y lazy eviction."""
import json
import sqlite3
import threading
import time
class CacheStore:
"""Cache key-value respaldado en SQLite con soporte de TTL y namespaces."""
_schema = """
CREATE TABLE IF NOT EXISTS cache (
namespace TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
created_at REAL NOT NULL,
expires_at REAL,
PRIMARY KEY (namespace, key)
);
"""
def __init__(self, db_path: str, namespace: str = "default") -> None:
self._db_path = db_path
self._namespace = namespace
self._hits = 0
self._misses = 0
self._lock = threading.Lock()
self._local = threading.local()
self._init_db()
def _conn(self) -> sqlite3.Connection:
"""Retorna una conexion SQLite thread-local."""
if not hasattr(self._local, "conn"):
conn = sqlite3.connect(self._db_path, check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
self._local.conn = conn
return self._local.conn
def _init_db(self) -> None:
conn = self._conn()
conn.execute(self._schema)
conn.commit()
def _evict_expired(self, conn: sqlite3.Connection) -> None:
"""Elimina entradas expiradas del namespace actual (lazy eviction)."""
now = time.time()
conn.execute(
"DELETE FROM cache WHERE namespace = ? AND expires_at IS NOT NULL AND expires_at <= ?",
(self._namespace, now),
)
def get(self, key: str) -> object:
"""Retorna el valor o None si no existe o esta expirado."""
with self._lock:
conn = self._conn()
self._evict_expired(conn)
conn.commit()
row = conn.execute(
"SELECT value FROM cache WHERE namespace = ? AND key = ?",
(self._namespace, key),
).fetchone()
if row is None:
self._misses += 1
return None
self._hits += 1
return json.loads(row[0])
def set(self, key: str, value: object, ttl: float = 0) -> None:
"""Almacena un valor. ttl en segundos; 0 = sin expiracion."""
now = time.time()
expires_at = (now + ttl) if ttl > 0 else None
with self._lock:
conn = self._conn()
conn.execute(
"""
INSERT INTO cache (namespace, key, value, created_at, expires_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(namespace, key) DO UPDATE SET
value = excluded.value,
created_at = excluded.created_at,
expires_at = excluded.expires_at
""",
(self._namespace, key, json.dumps(value), now, expires_at),
)
conn.commit()
def delete(self, key: str) -> bool:
"""Elimina una entrada. Retorna True si existia."""
with self._lock:
conn = self._conn()
cursor = conn.execute(
"DELETE FROM cache WHERE namespace = ? AND key = ?",
(self._namespace, key),
)
conn.commit()
return cursor.rowcount > 0
def clear(self) -> int:
"""Elimina todas las entradas del namespace. Retorna filas eliminadas."""
with self._lock:
conn = self._conn()
cursor = conn.execute(
"DELETE FROM cache WHERE namespace = ?",
(self._namespace,),
)
conn.commit()
return cursor.rowcount
def stats(self) -> dict:
"""Retorna estadisticas del store: hits, misses y size actual."""
with self._lock:
conn = self._conn()
self._evict_expired(conn)
conn.commit()
row = conn.execute(
"SELECT COUNT(*) FROM cache WHERE namespace = ?",
(self._namespace,),
).fetchone()
size = row[0] if row else 0
return {"hits": self._hits, "misses": self._misses, "size": size}
def get_or_set(self, key: str, factory: callable, ttl: float = 0) -> object:
"""Retorna el valor cacheado o llama factory() y lo almacena."""
value = self.get(key)
if value is None:
value = factory()
self.set(key, value, ttl)
return value
def cache_to_sqlite(db_path: str, namespace: str = "default") -> CacheStore:
"""Crea un CacheStore respaldado en SQLite.
Args:
db_path: Ruta al archivo SQLite (se crea si no existe).
namespace: Espacio de nombres logico dentro de la base de datos.
Returns:
CacheStore con metodos get/set/delete/clear/stats/get_or_set.
"""
return CacheStore(db_path, namespace)
@@ -0,0 +1,114 @@
"""Tests para cache_to_sqlite."""
import os
import tempfile
import threading
import time
import pytest
from .cache_to_sqlite import cache_to_sqlite
@pytest.fixture
def store(tmp_path):
db = str(tmp_path / "test.db")
return cache_to_sqlite(db)
@pytest.fixture
def store2(tmp_path):
"""Segundo namespace en la misma BD."""
db = str(tmp_path / "test.db")
return cache_to_sqlite(db, namespace="other")
@pytest.fixture
def store_and_other(tmp_path):
db = str(tmp_path / "test.db")
s1 = cache_to_sqlite(db, namespace="ns1")
s2 = cache_to_sqlite(db, namespace="ns2")
return s1, s2
def test_set_y_get_basico(store):
store.set("foo", {"x": 1})
assert store.get("foo") == {"x": 1}
def test_ttl_expirado_retorna_none(store):
store.set("expiring", "hello", ttl=0.05)
time.sleep(0.1)
assert store.get("expiring") is None
def test_ttl_cero_nunca_expira(store):
store.set("forever", 42, ttl=0)
time.sleep(0.05)
assert store.get("forever") == 42
def test_get_or_set_factory_solo_se_llama_en_miss(store):
calls = []
def factory():
calls.append(1)
return "computed"
result1 = store.get_or_set("key", factory, ttl=10)
result2 = store.get_or_set("key", factory, ttl=10)
assert result1 == "computed"
assert result2 == "computed"
assert len(calls) == 1
def test_namespaces_independientes(store_and_other):
s1, s2 = store_and_other
s1.set("k", "from_ns1")
assert s2.get("k") is None
s2.set("k", "from_ns2")
assert s1.get("k") == "from_ns1"
assert s2.get("k") == "from_ns2"
def test_clear_elimina_solo_el_namespace(store_and_other):
s1, s2 = store_and_other
s1.set("a", 1)
s2.set("b", 2)
removed = s1.clear()
assert removed == 1
assert s1.get("a") is None
assert s2.get("b") == 2
def test_stats_contadores_correctos(store):
store.set("x", 10)
store.get("x") # hit
store.get("x") # hit
store.get("z") # miss
s = store.stats()
assert s["hits"] == 2
assert s["misses"] == 1
assert s["size"] == 1
def test_concurrencia(tmp_path):
db = str(tmp_path / "concurrent.db")
s = cache_to_sqlite(db, "parallel")
errors = []
def worker(i):
try:
s.set(f"key_{i}", i)
val = s.get(f"key_{i}")
assert val == i
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=worker, args=(i,)) for i in range(20)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"Errors in threads: {errors}"
+36
View File
@@ -0,0 +1,36 @@
---
name: get_logger
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def get_logger(name: str = 'app') -> logging.Logger"
description: "Devuelve un logger existente si ya tiene handlers, o lo crea con setup_logger. Util en modulos internos que no controlan la inicializacion del logger."
tags: [logging, logger, infra, utility]
uses_functions: [setup_logger_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [logging]
tested: true
tests:
- "get_logger retorna logger configurado"
test_file_path: "python/functions/infra/setup_logger_test.py"
file_path: "python/functions/infra/setup_logger.py"
---
## Ejemplo
```python
from setup_logger import get_logger
# En cualquier modulo, sin preocuparse de si el logger ya fue inicializado
log = get_logger("mi_app")
log.info("Mensaje desde un modulo interno")
```
## Notas
Companion de `setup_logger`. Si el logger tiene handlers (ya fue configurado), lo devuelve tal cual. Si no, llama a `setup_logger` con valores por defecto (log_dir="logs"). Comparten el mismo archivo de implementacion.
@@ -0,0 +1,40 @@
---
name: http_download_file
kind: function
lang: py
domain: infra
version: "1.0.0"
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"
description: "Descarga un archivo por HTTP en streaming (sin cargar todo en memoria). Crea directorios intermedios si no existen. Retorna dict con path, size_bytes y content_type."
tags: [http, download, file, streaming, network, stdlib, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["os", "urllib.error", "urllib.request"]
tested: true
tests:
- "mock de descarga con contenido binario"
- "directorio destino creado automaticamente"
- "retorno con size correcto"
- "timeout configurado en el request"
test_file_path: "python/functions/infra/http_download_file_test.py"
file_path: "python/functions/infra/http_download_file.py"
---
## Ejemplo
```python
result = http_download_file(
"https://example.com/report.pdf",
dest_path="/tmp/reports/report.pdf",
timeout=60.0,
)
print(f"Downloaded {result['size_bytes']} bytes to {result['path']}")
```
## Notas
Solo usa stdlib (urllib, os). La descarga se hace en chunks de `chunk_size` bytes para evitar consumo de memoria con archivos grandes. El timeout de 120s por defecto es mayor que http_get_json porque los archivos pueden ser pesados. Los directorios intermedios se crean con os.makedirs(exist_ok=True).
@@ -0,0 +1,60 @@
"""Descarga de archivos en streaming — HTTP client sin dependencias externas."""
import os
import urllib.error
import urllib.request
def http_download_file(
url: str,
dest_path: str,
headers: dict[str, str] | None = None,
timeout: float = 120.0,
chunk_size: int = 8192,
) -> dict:
"""Descarga un archivo por HTTP en streaming (sin cargar todo en memoria).
Crea los directorios intermedios si no existen. Si el archivo destino
ya existe lo sobreescribe. La descarga se hace en chunks para evitar
consumo de memoria excesivo con archivos grandes.
Args:
url: URL del archivo a descargar.
dest_path: Ruta local destino donde guardar el archivo.
headers: Headers HTTP adicionales.
timeout: Segundos maximo de espera para la conexion (default 120).
chunk_size: Tamano de cada chunk en bytes (default 8192).
Returns:
dict con campos ``path`` (str), ``size_bytes`` (int) y
``content_type`` (str).
Raises:
RuntimeError: Si el status HTTP es >= 400.
"""
req = urllib.request.Request(url, headers=headers or {}, method="GET")
os.makedirs(os.path.dirname(os.path.abspath(dest_path)), exist_ok=True)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
content_type: str = resp.headers.get("Content-Type", "")
size_bytes = 0
with open(dest_path, "wb") as f:
while True:
chunk = resp.read(chunk_size)
if not chunk:
break
f.write(chunk)
size_bytes += len(chunk)
except urllib.error.HTTPError as e:
short_url = url[:100] if len(url) > 100 else url
raise RuntimeError(
f"http_download_file: HTTP {e.code} at {short_url!r}"
) from e
return {
"path": dest_path,
"size_bytes": size_bytes,
"content_type": content_type,
}
@@ -0,0 +1,84 @@
"""Tests para http_download_file."""
import sys
import tempfile
import os
import unittest
from unittest.mock import MagicMock, patch
sys.path.insert(0, "/home/lucas/fn_registry/python/functions")
from infra.http_download_file import http_download_file
def _make_response(content: bytes, content_type: str = "application/octet-stream"):
resp = MagicMock()
# Simula lectura en chunks
chunks = [content[i:i+8192] for i in range(0, len(content), 8192)] + [b""]
resp.read.side_effect = chunks
resp.headers = {"Content-Type": content_type}
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
class TestHttpDownloadFile(unittest.TestCase):
def test_mock_descarga_con_contenido_binario(self):
content = b"\x00\x01\x02\x03" * 100
mock_resp = _make_response(content, "application/octet-stream")
with tempfile.TemporaryDirectory() as tmpdir:
dest = os.path.join(tmpdir, "file.bin")
with patch("urllib.request.urlopen", return_value=mock_resp):
result = http_download_file("http://example.com/file.bin", dest)
self.assertEqual(result["size_bytes"], len(content))
self.assertEqual(result["path"], dest)
with open(dest, "rb") as f:
self.assertEqual(f.read(), content)
def test_directorio_destino_creado_automaticamente(self):
content = b"hello binary"
mock_resp = _make_response(content)
with tempfile.TemporaryDirectory() as tmpdir:
dest = os.path.join(tmpdir, "nested", "deep", "file.bin")
self.assertFalse(os.path.exists(os.path.dirname(dest)))
with patch("urllib.request.urlopen", return_value=mock_resp):
http_download_file("http://example.com/file.bin", dest)
self.assertTrue(os.path.exists(dest))
def test_retorno_con_size_correcto(self):
content = b"x" * 5000
mock_resp = _make_response(content, "text/plain")
with tempfile.TemporaryDirectory() as tmpdir:
dest = os.path.join(tmpdir, "out.txt")
with patch("urllib.request.urlopen", return_value=mock_resp):
result = http_download_file("http://example.com/data.txt", dest)
self.assertEqual(result["size_bytes"], 5000)
self.assertEqual(result["content_type"], "text/plain")
def test_timeout_configurado_en_el_request(self):
content = b"data"
mock_resp = _make_response(content)
captured_timeout = []
def fake_urlopen(req, timeout=None):
captured_timeout.append(timeout)
return mock_resp
with tempfile.TemporaryDirectory() as tmpdir:
dest = os.path.join(tmpdir, "file.bin")
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
http_download_file("http://example.com/file.bin", dest, timeout=60.0)
self.assertEqual(captured_timeout[0], 60.0)
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -0,0 +1,41 @@
---
name: http_get_json
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "http_get_json(url: str, headers: dict[str, str] | None = None, params: dict[str, str] | None = None, timeout: float = 30.0) -> dict"
description: "GET request que espera JSON. Agrega Accept: application/json automaticamente. Lanza RuntimeError si status >= 400 con status code, url truncada y primeros 200 chars del body."
tags: [http, json, get, client, network, stdlib, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "urllib.error", "urllib.parse", "urllib.request"]
tested: true
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"
test_file_path: "python/functions/infra/http_get_json_test.py"
file_path: "python/functions/infra/http_get_json.py"
---
## Ejemplo
```python
data = http_get_json(
"https://api.example.com/users",
params={"page": "1", "limit": "50"},
headers={"X-Api-Key": "secret"},
)
print(data["total"])
```
## Notas
Solo usa stdlib (urllib). Sin dependencias externas. El error incluye los primeros 200 chars del body para facilitar debugging en produccion. Params se serializa con urlencode antes de concatenar a la URL.
+58
View File
@@ -0,0 +1,58 @@
"""GET request JSON — HTTP client sin dependencias externas."""
import json
import urllib.error
import urllib.parse
import urllib.request
def http_get_json(
url: str,
headers: dict[str, str] | None = None,
params: dict[str, str] | None = None,
timeout: float = 30.0,
) -> dict:
"""Realiza un GET request y parsea la respuesta como JSON.
Agrega automaticamente el header ``Accept: application/json``.
Si el status es >= 400 lanza RuntimeError con status code, url y
los primeros 200 caracteres del body para facilitar el debugging.
Args:
url: URL del endpoint.
headers: Headers HTTP adicionales. Se fusionan con Accept por defecto.
params: Query string params. Se serializa con urllib.parse.urlencode.
timeout: Segundos maximo de espera (default 30).
Returns:
Respuesta parseada como dict o list.
Raises:
RuntimeError: Si status >= 400 o si el body no es JSON valido.
"""
if params:
url = f"{url}?{urllib.parse.urlencode(params)}"
all_headers: dict[str, str] = {"Accept": "application/json"}
if headers:
all_headers.update(headers)
req = urllib.request.Request(url, headers=all_headers, method="GET")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
except urllib.error.HTTPError as e:
body_preview = e.read(200).decode("utf-8", errors="replace")
short_url = url[:100] if len(url) > 100 else url
raise RuntimeError(
f"http_get_json: HTTP {e.code} at {short_url!r}{body_preview}"
) from e
try:
return json.loads(raw)
except json.JSONDecodeError as e:
preview = raw[:200].decode("utf-8", errors="replace")
raise RuntimeError(
f"http_get_json: response is not valid JSON — {preview}"
) from e
@@ -0,0 +1,87 @@
"""Tests para http_get_json."""
import json
import sys
import unittest
import urllib.error
import urllib.request
from io import BytesIO
from unittest.mock import MagicMock, patch
sys.path.insert(0, "/home/lucas/fn_registry/python/functions")
from infra.http_get_json import http_get_json
def _make_response(data: bytes, status: int = 200, content_type: str = "application/json"):
"""Crea un mock de HTTPResponse."""
resp = MagicMock()
resp.read.return_value = data
resp.status = status
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
class TestHttpGetJson(unittest.TestCase):
def test_mock_respuesta_200_con_json(self):
payload = {"ok": True, "value": 42}
mock_resp = _make_response(json.dumps(payload).encode())
with patch("urllib.request.urlopen", return_value=mock_resp):
result = http_get_json("http://example.com/api")
self.assertEqual(result, payload)
def test_mock_respuesta_404_error_con_status_code(self):
err = urllib.error.HTTPError(
url="http://example.com/missing",
code=404,
msg="Not Found",
hdrs=None, # type: ignore[arg-type]
fp=BytesIO(b"not found"),
)
with patch("urllib.request.urlopen", side_effect=err):
with self.assertRaises(RuntimeError) as ctx:
http_get_json("http://example.com/missing")
self.assertIn("404", str(ctx.exception))
def test_mock_respuesta_json_invalido_error_descriptivo(self):
mock_resp = _make_response(b"not-json!!!")
with patch("urllib.request.urlopen", return_value=mock_resp):
with self.assertRaises(RuntimeError) as ctx:
http_get_json("http://example.com/api")
self.assertIn("not valid JSON", str(ctx.exception))
def test_params_serializados_como_query_string(self):
captured_url = []
def fake_urlopen(req, timeout=None):
captured_url.append(req.full_url)
return _make_response(b"{}")
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
http_get_json("http://example.com/api", params={"page": "1", "limit": "10"})
url = captured_url[0]
self.assertIn("page=1", url)
self.assertIn("limit=10", url)
def test_headers_custom_enviados(self):
captured_headers = []
def fake_urlopen(req, timeout=None):
captured_headers.append(dict(req.headers))
return _make_response(b'{"x": 1}')
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
http_get_json("http://example.com/api", headers={"X-Api-Key": "secret"})
# urllib capitaliza el primer caracter de cada header
headers_lower = {k.lower(): v for k, v in captured_headers[0].items()}
self.assertIn("x-api-key", headers_lower)
self.assertEqual(headers_lower["x-api-key"], "secret")
self.assertIn("accept", headers_lower)
if __name__ == "__main__":
unittest.main()
+40
View File
@@ -0,0 +1,40 @@
---
name: http_post_json
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "http_post_json(url: str, body: dict, headers: dict[str, str] | None = None, timeout: float = 30.0) -> dict"
description: "POST request con body JSON. Agrega Content-Type: application/json y Accept: application/json. Lanza RuntimeError si status >= 400 con status code, url truncada y primeros 200 chars del body."
tags: [http, json, post, client, network, stdlib, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "urllib.error", "urllib.request"]
tested: true
tests:
- "mock de POST con body serializado correctamente"
- "mock de respuesta 201"
- "mock de respuesta 500 → error"
- "body con unicode"
test_file_path: "python/functions/infra/http_post_json_test.py"
file_path: "python/functions/infra/http_post_json.py"
---
## Ejemplo
```python
response = http_post_json(
"https://api.example.com/users",
body={"name": "Alice", "role": "admin"},
headers={"X-Api-Key": "secret"},
)
print(response["id"])
```
## Notas
Solo usa stdlib (urllib). El body se serializa con json.dumps(ensure_ascii=False) y se codifica a UTF-8. Headers custom se fusionan con Content-Type y Accept por defecto (los custom tienen precedencia).
+58
View File
@@ -0,0 +1,58 @@
"""POST request JSON — HTTP client sin dependencias externas."""
import json
import urllib.error
import urllib.request
def http_post_json(
url: str,
body: dict,
headers: dict[str, str] | None = None,
timeout: float = 30.0,
) -> dict:
"""Realiza un POST request con body JSON y parsea la respuesta como JSON.
Agrega automaticamente ``Content-Type: application/json`` y
``Accept: application/json``. Si el status es >= 400 lanza RuntimeError
con status code, url y los primeros 200 caracteres del body.
Args:
url: URL del endpoint.
body: Datos a serializar como JSON en el cuerpo del request.
headers: Headers HTTP adicionales. Se fusionan con los defaults.
timeout: Segundos maximo de espera (default 30).
Returns:
Respuesta parseada como dict o list.
Raises:
RuntimeError: Si status >= 400 o si el body de respuesta no es JSON valido.
"""
all_headers: dict[str, str] = {
"Content-Type": "application/json",
"Accept": "application/json",
}
if headers:
all_headers.update(headers)
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url, data=data, headers=all_headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
except urllib.error.HTTPError as e:
body_preview = e.read(200).decode("utf-8", errors="replace")
short_url = url[:100] if len(url) > 100 else url
raise RuntimeError(
f"http_post_json: HTTP {e.code} at {short_url!r}{body_preview}"
) from e
try:
return json.loads(raw)
except json.JSONDecodeError as e:
preview = raw[:200].decode("utf-8", errors="replace")
raise RuntimeError(
f"http_post_json: response is not valid JSON — {preview}"
) from e
@@ -0,0 +1,76 @@
"""Tests para http_post_json."""
import json
import sys
import unittest
import urllib.error
from io import BytesIO
from unittest.mock import MagicMock, patch
sys.path.insert(0, "/home/lucas/fn_registry/python/functions")
from infra.http_post_json import http_post_json
def _make_response(data: bytes, status: int = 200):
resp = MagicMock()
resp.read.return_value = data
resp.status = status
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
class TestHttpPostJson(unittest.TestCase):
def test_mock_post_body_serializado_correctamente(self):
captured = []
def fake_urlopen(req, timeout=None):
captured.append(req.data)
return _make_response(b'{"created": true}')
body = {"name": "test", "value": 99}
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
http_post_json("http://example.com/api", body)
sent = json.loads(captured[0])
self.assertEqual(sent["name"], "test")
self.assertEqual(sent["value"], 99)
def test_mock_respuesta_201(self):
mock_resp = _make_response(b'{"id": 1}', status=201)
with patch("urllib.request.urlopen", return_value=mock_resp):
result = http_post_json("http://example.com/api", {"x": 1})
self.assertEqual(result, {"id": 1})
def test_mock_respuesta_500_error(self):
err = urllib.error.HTTPError(
url="http://example.com/api",
code=500,
msg="Internal Server Error",
hdrs=None, # type: ignore[arg-type]
fp=BytesIO(b"server error details"),
)
with patch("urllib.request.urlopen", side_effect=err):
with self.assertRaises(RuntimeError) as ctx:
http_post_json("http://example.com/api", {"x": 1})
self.assertIn("500", str(ctx.exception))
def test_body_con_unicode(self):
captured = []
def fake_urlopen(req, timeout=None):
captured.append(req.data)
return _make_response(b'{"ok": true}')
body = {"mensaje": "Hola mundo \u00e9\u00e0\u00fc \U0001f600"}
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
http_post_json("http://example.com/api", body)
decoded = json.loads(captured[0].decode("utf-8"))
self.assertEqual(decoded["mensaje"], body["mensaje"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,49 @@
---
name: normalize_zip_filenames
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def normalize_zip_filenames(zipf: zipfile.ZipFile) -> None"
description: "Repara nombres de archivos UTF-8 en ZIPs que no tienen el flag UTF-8 seteado (0x800). Comun en archivos creados en Windows con nombres CJK (chino, japones, coreano). Detecta mojibake comparando rangos Unicode y recodifica CP437 -> UTF-8."
tags: [zip, encoding, utf-8, cjk, mojibake, normalize, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [zipfile]
tested: true
tests:
- "ZIP con nombres UTF-8 correctos no se modifican"
- "ZIP con nombres CJK mojibake se reparan"
test_file_path: "python/functions/infra/safe_extract_zip_test.py"
file_path: "python/functions/infra/safe_extract_zip.py"
---
## Ejemplo
```python
import zipfile
from normalize_zip_filenames import normalize_zip_filenames
with zipfile.ZipFile("archivo_windows.zip", "r") as zipf:
normalize_zip_filenames(zipf)
for info in zipf.infolist():
print(info.filename) # nombres CJK correctos
```
## Notas
Funcion impure: modifica los `ZipInfo` del objeto ZipFile in-place.
El flag `0x800` en `flag_bits` indica que el filename ya esta codificado en UTF-8 segun la especificacion PKZip. Si esta seteado, el nombre no se toca.
Deteccion de CJK: rangos `\u3400-\u4dbf`, `\u4e00-\u9fff`, `\u3000-\u303f`, `\uff00-\uffef`.
Deteccion de mojibake: rangos Greek (`\u0370-\u03ff`), Math (`\u2200-\u22ff`), Box Drawing (`\u2500-\u257f`). Estos caracteres aparecen cuando bytes UTF-8 se interpretan como CP437.
Si se reparo algun nombre, se setea `zipf.metadata_encoding = "utf-8"`.
El codigo fuente de ambas funciones vive en `safe_extract_zip.py`.
@@ -0,0 +1,45 @@
---
name: read_file_with_encoding
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "read_file_with_encoding(path: str, encodings: list[str] | None = None) -> str"
description: "Lee un archivo de texto intentando multiples encodings en orden hasta encontrar uno que funcione. Util para archivos de origen desconocido (Windows, Latin-1, con BOM, etc.)."
tags: [file, encoding, io, text, utf8, latin1, cp1252, decode]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests:
- "archivo utf-8 valido"
- "archivo utf-8 con BOM eliminado con utf-8-sig"
- "archivo latin-1"
- "archivo binario falla con ValueError"
- "encodings personalizados"
- "archivo no existe lanza FileNotFoundError"
test_file_path: "python/functions/infra/read_file_with_encoding_test.py"
file_path: "python/functions/infra/read_file_with_encoding.py"
---
## Ejemplo
```python
# Leer archivo de origen desconocido
content = read_file_with_encoding("/tmp/datos.csv")
# Leer archivo Windows con BOM explicitamente
content = read_file_with_encoding("/tmp/report.txt", encodings=["utf-8-sig", "cp1252"])
```
## Notas
Los encodings por defecto son `["utf-8", "utf-8-sig", "latin-1", "cp1252"]`. El orden importa: `utf-8` se intenta primero porque es el mas comun. Si el archivo tiene BOM y se quiere que sea eliminado automaticamente, pasar `encodings=["utf-8-sig"]` o anteponerlo a `utf-8` en la lista personalizada.
`latin-1` nunca lanza `UnicodeDecodeError` porque mapea todos los bytes 0x00-0xFF, por lo que actua como fallback universal. Si `latin-1` es el ultimo encoding y falla con `cp1252` tambien, solo un archivo binario puro (sin mapeo posible) disparara el `ValueError`.
Raises `FileNotFoundError` u `OSError` nativas si el archivo no existe o hay error de I/O — estos no se envuelven en `ValueError`.
@@ -0,0 +1,45 @@
"""Lee un archivo de texto intentando multiples encodings en orden."""
from __future__ import annotations
def read_file_with_encoding(
path: str,
encodings: list[str] | None = None,
) -> str:
"""Lee un archivo de texto intentando multiples encodings en orden.
Intenta abrir el archivo con cada encoding de la lista hasta que
uno tenga exito. Util para archivos de origen desconocido (Windows,
Latin-1, archivos con BOM, etc.).
Args:
path: Ruta al archivo a leer.
encodings: Lista de encodings a intentar en orden. Por defecto
["utf-8", "utf-8-sig", "latin-1", "cp1252"].
Returns:
Contenido del archivo como string.
Raises:
ValueError: Si ningun encoding logra decodificar el archivo.
FileNotFoundError: Si el archivo no existe.
OSError: Si hay un error de I/O al abrir el archivo.
"""
if encodings is None:
encodings = ["utf-8", "utf-8-sig", "latin-1", "cp1252"]
last_error: UnicodeDecodeError | None = None
for encoding in encodings:
try:
with open(path, encoding=encoding) as fh:
return fh.read()
except UnicodeDecodeError as exc:
last_error = exc
continue
raise ValueError(
f"Unable to decode file '{path}' with encodings {encodings}. "
f"Last error: {last_error}"
)
@@ -0,0 +1,81 @@
"""Tests para read_file_with_encoding."""
import os
import sys
import tempfile
from pathlib import Path
import pytest
_HERE = Path(__file__).parent
if str(_HERE) not in sys.path:
sys.path.insert(0, str(_HERE))
from read_file_with_encoding import read_file_with_encoding # noqa: E402
def _write_bytes(content: bytes) -> str:
"""Escribe bytes a un archivo temporal y retorna su path."""
fd, path = tempfile.mkstemp()
try:
os.write(fd, content)
finally:
os.close(fd)
return path
def test_archivo_utf8():
texto = "Hola mundo con acentos: áéíóú"
path = _write_bytes(texto.encode("utf-8"))
try:
result = read_file_with_encoding(path)
assert result == texto
finally:
os.unlink(path)
def test_archivo_utf8_con_bom():
texto = "Contenido con BOM"
path = _write_bytes(texto.encode("utf-8-sig"))
try:
# Usando utf-8-sig explicitamente para que el BOM sea eliminado
result = read_file_with_encoding(path, encodings=["utf-8-sig"])
assert result == texto
finally:
os.unlink(path)
def test_archivo_latin1():
texto = "Texto en Latin-1: café"
path = _write_bytes(texto.encode("latin-1"))
try:
result = read_file_with_encoding(path)
assert result == texto
finally:
os.unlink(path)
def test_archivo_binario_falla():
# Bytes que no son validos en ningun encoding de texto comun
path = _write_bytes(bytes([0x80, 0x81, 0x82, 0x83, 0xFF, 0xFE, 0x00, 0x01]))
try:
with pytest.raises(ValueError, match="Unable to decode file"):
# Forzar solo encodings estrictos para que falle con binario puro
read_file_with_encoding(path, encodings=["utf-8", "utf-8-sig"])
finally:
os.unlink(path)
def test_encodings_personalizados():
texto = "Windows text: Ñoño"
path = _write_bytes(texto.encode("cp1252"))
try:
result = read_file_with_encoding(path, encodings=["cp1252"])
assert result == texto
finally:
os.unlink(path)
def test_archivo_no_existe():
with pytest.raises(FileNotFoundError):
read_file_with_encoding("/tmp/archivo_que_no_existe_12345.txt")
@@ -0,0 +1,46 @@
---
name: safe_extract_zip
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def safe_extract_zip(zip_path: str, dest_dir: str) -> None"
description: "Extrae un archivo ZIP con proteccion contra Zip Slip (path traversal attack). Valida que cada archivo extraido quede dentro del directorio destino antes de extraerlo. Normaliza nombres de archivo UTF-8 antes de extraer."
tags: [zip, extract, security, zip-slip, path-traversal, infra, io]
uses_functions: [normalize_zip_filenames_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [os, zipfile, pathlib]
tested: true
tests:
- "ZIP normal extrae correctamente dentro del destino"
- "ZIP con path traversal lanza ValueError"
- "ZIP con paths absolutos lanza ValueError"
test_file_path: "python/functions/infra/safe_extract_zip_test.py"
file_path: "python/functions/infra/safe_extract_zip.py"
---
## Ejemplo
```python
from safe_extract_zip import safe_extract_zip
# Extraccion segura
try:
safe_extract_zip("archive.zip", "/tmp/output")
except ValueError as e:
print(f"Zip Slip bloqueado: {e}")
except zipfile.BadZipFile:
print("Archivo ZIP invalido")
```
## Notas
Funcion impura: escribe archivos en disco.
La proteccion contra Zip Slip consiste en resolver el path absoluto de cada miembro antes de extraerlo y verificar que empiece con `str(dest_dir) + os.sep`. Esto bloquea tanto `../../etc/passwd` como `/etc/passwd`.
La normalizacion de nombres UTF-8 se delega a `normalize_zip_filenames` y se ejecuta antes de la validacion de paths.
@@ -0,0 +1,80 @@
"""Safe ZIP extraction with Zip Slip protection and filename normalization."""
import os
import zipfile
from pathlib import Path
def normalize_zip_filenames(zipf: zipfile.ZipFile) -> None:
"""Repara nombres de archivos UTF-8 en ZIPs sin el flag UTF-8 seteado.
Args:
zipf: Objeto ZipFile abierto en modo lectura.
Returns:
None. Modifica los infolist del ZipFile in-place.
"""
def _is_cjk(s: str) -> bool:
return any(
"\u3400" <= c <= "\u4dbf"
or "\u4e00" <= c <= "\u9fff"
or "\u3000" <= c <= "\u303f"
or "\uff00" <= c <= "\uffef"
for c in s
)
def _is_mojibake(s: str) -> bool:
return any(
"\u0370" <= c <= "\u03ff" # Greek
or "\u2200" <= c <= "\u22ff" # Math
or "\u2500" <= c <= "\u257f" # Box Drawing
for c in s
)
repaired = False
for info in zipf.infolist():
# Flag 0x800 indica que el filename ya esta en UTF-8
if info.flag_bits & 0x800:
continue
try:
repaired_name = info.filename.encode("cp437").decode("utf-8")
if _is_cjk(repaired_name) and _is_mojibake(info.filename):
info.filename = repaired_name
repaired = True
except (UnicodeEncodeError, UnicodeDecodeError):
pass
if repaired:
zipf.metadata_encoding = "utf-8"
def safe_extract_zip(zip_path: str, dest_dir: str) -> None:
"""Extrae un archivo ZIP con proteccion contra Zip Slip (path traversal).
Valida que cada archivo extraido quede dentro del directorio destino antes
de extraerlo. Normaliza los nombres de archivo UTF-8 antes de extraer.
Args:
zip_path: Ruta al archivo ZIP a extraer.
dest_dir: Directorio de destino para la extraccion.
Raises:
ValueError: Si se detecta un intento de Zip Slip (path traversal).
zipfile.BadZipFile: Si el archivo no es un ZIP valido.
FileNotFoundError: Si zip_path no existe.
"""
dest = Path(dest_dir).resolve()
with zipfile.ZipFile(zip_path, "r") as zipf:
normalize_zip_filenames(zipf)
for member in zipf.infolist():
member_path = (dest / member.filename).resolve()
# Verificar que el path resultante este dentro de dest_dir
if not str(member_path).startswith(str(dest) + os.sep):
raise ValueError(
f"Zip Slip attempt detected: {member.filename!r} would extract to {member_path}"
)
zipf.extract(member, dest)
@@ -0,0 +1,206 @@
"""Tests para safe_extract_zip y normalize_zip_filenames."""
import io
import os
import struct
import tempfile
import zipfile
from safe_extract_zip import normalize_zip_filenames, safe_extract_zip
def _make_zip_with_raw_filename(raw_filename_bytes: bytes, content: bytes) -> bytes:
"""Crea un ZIP minimal con bytes de filename raw y sin flag 0x800.
Simula un ZIP creado en Windows donde el filename tiene bytes UTF-8
pero sin el flag de UTF-8 (0x800), causando que zipfile lo lea como CP437.
"""
crc = zipfile.crc32(content) & 0xFFFFFFFF
fname_len = len(raw_filename_bytes)
buf = io.BytesIO()
# Local file header
local_header = struct.pack(
"<4sHHHHHIIIHH",
b"PK\x03\x04", # signature
20, # version needed
0, # general purpose bit flag — sin 0x800
0, # compression: stored
0, # last mod time
0, # last mod date
crc,
len(content), # compressed size
len(content), # uncompressed size
fname_len,
0, # extra field length
)
buf.write(local_header)
buf.write(raw_filename_bytes)
buf.write(content)
# Central directory header
cd_offset = buf.tell()
cd_header = struct.pack(
"<4sHHHHHHIIIHHHHHII",
b"PK\x01\x02",
20, # version made by
20, # version needed
0, # flag — sin 0x800
0, # compression
0, # mod time
0, # mod date
crc,
len(content), # compressed size
len(content), # uncompressed size
fname_len,
0, # extra length
0, # comment length
0, # disk start
0, # internal attr
0, # external attr
0, # local header offset
)
buf.write(cd_header)
buf.write(raw_filename_bytes)
# End of central directory
eocd = struct.pack(
"<4sHHHHIIH",
b"PK\x05\x06",
0, 0, 1, 1,
len(cd_header) + fname_len,
cd_offset,
0,
)
buf.write(eocd)
return buf.getvalue()
def _make_zip(members: dict[str, bytes]) -> str:
"""Crea un ZIP temporal con los miembros dados {filename: content}."""
tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
with zipfile.ZipFile(tmp, "w") as zipf:
for name, content in members.items():
zipf.writestr(name, content)
tmp.close()
return tmp.name
def _make_zip_with_traversal(traversal_name: str) -> str:
"""Crea un ZIP con un miembro cuyo nombre intenta path traversal."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zipf:
info = zipfile.ZipInfo(traversal_name)
zipf.writestr(info, b"malicious content")
tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
tmp.write(buf.getvalue())
tmp.close()
return tmp.name
def test_zip_normal():
"""ZIP normal extrae correctamente dentro del destino."""
zip_path = _make_zip({"hello.txt": b"hello world", "subdir/file.py": b"# code"})
try:
with tempfile.TemporaryDirectory() as dest:
safe_extract_zip(zip_path, dest)
assert os.path.isfile(os.path.join(dest, "hello.txt"))
assert os.path.isfile(os.path.join(dest, "subdir", "file.py"))
with open(os.path.join(dest, "hello.txt"), "rb") as f:
assert f.read() == b"hello world"
finally:
os.unlink(zip_path)
def test_zip_con_path_traversal():
"""ZIP con path traversal lanza ValueError."""
zip_path = _make_zip_with_traversal("../../etc/passwd")
try:
with tempfile.TemporaryDirectory() as dest:
raised = False
try:
safe_extract_zip(zip_path, dest)
except ValueError as e:
raised = True
assert "Zip Slip" in str(e)
assert raised, "Expected ValueError for path traversal"
finally:
os.unlink(zip_path)
def test_zip_con_paths_absolutos():
"""ZIP con paths absolutos lanza ValueError."""
zip_path = _make_zip_with_traversal("/etc/passwd")
try:
with tempfile.TemporaryDirectory() as dest:
raised = False
try:
safe_extract_zip(zip_path, dest)
except ValueError as e:
raised = True
assert "Zip Slip" in str(e)
assert raised, "Expected ValueError for absolute path"
finally:
os.unlink(zip_path)
def test_normalize_utf8_correctos_no_cambian():
"""ZIP con nombres UTF-8 correctos (flag 0x800) no se modifican."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zipf:
info = zipfile.ZipInfo("archivo_normal.txt")
info.flag_bits |= 0x800 # marcar como UTF-8
zipf.writestr(info, b"content")
buf.seek(0)
with zipfile.ZipFile(buf, "r") as zipf:
original_name = zipf.infolist()[0].filename
normalize_zip_filenames(zipf)
assert zipf.infolist()[0].filename == original_name
def test_normalize_cjk_mojibake_repara():
"""ZIP con nombres CJK en mojibake (UTF-8 bytes leidos como CP437) se reparan.
Simula un ZIP donde los bytes del filename son UTF-8 valido de un nombre CJK,
pero el flag 0x800 no esta seteado, asi que zipfile los decodifica como CP437
produciendo mojibake. normalize_zip_filenames debe detectarlo y repararlo.
"""
cjk_name = "\u6587\u4ef6.txt" # 文件.txt
# Construir ZIP con bytes UTF-8 crudos en el campo filename, sin flag 0x800.
# Python no permite esto via ZipInfo (fuerza 0x800 para non-ASCII), por eso
# construimos el ZIP manualmente con _make_zip_with_raw_filename.
utf8_bytes = cjk_name.encode("utf-8")
zip_bytes = _make_zip_with_raw_filename(utf8_bytes, b"cjk content")
with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as zipf:
member = zipf.infolist()[0]
# Sin el flag, zipfile lee los bytes como CP437: debe ser mojibake
assert not (member.flag_bits & 0x800), "Flag 0x800 no deberia estar seteado"
assert member.filename != cjk_name, "El nombre aun no debe estar reparado"
normalize_zip_filenames(zipf)
repaired = zipf.infolist()[0].filename
has_cjk = any(
"\u4e00" <= c <= "\u9fff" or "\u3400" <= c <= "\u4dbf" for c in repaired
)
assert has_cjk, f"Esperaba CJK en nombre reparado, got: {repaired!r}"
if __name__ == "__main__":
test_zip_normal()
print("PASS: ZIP normal extrae correctamente dentro del destino")
test_zip_con_path_traversal()
print("PASS: ZIP con path traversal lanza ValueError")
test_zip_con_paths_absolutos()
print("PASS: ZIP con paths absolutos lanza ValueError")
test_normalize_utf8_correctos_no_cambian()
print("PASS: ZIP con nombres UTF-8 correctos no se modifican")
test_normalize_cjk_mojibake_repara()
print("PASS: ZIP con nombres CJK mojibake se reparan")
print("\nAll tests passed.")
+64
View File
@@ -0,0 +1,64 @@
---
name: scan_directory
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def 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"
description: "Recorre un arbol de directorios y clasifica cada archivo como procesable o no soportado. Util para validacion pre-importacion de directorios. Ignora dot files, symlinks, archivos vacios y directorios de build/venv/cache predefinidos. Soporta filtros include/exclude con globs."
tags: [directory, scan, filesystem, classification, infra, walk, files]
uses_functions: []
uses_types: [classified_file_py_infra, directory_scan_result_py_infra]
returns: [directory_scan_result_py_infra]
returns_optional: false
error_type: "error_go_core"
imports: [os, pathlib, fnmatch, sys, dataclasses]
tested: true
tests:
- "directorio con mezcla de archivos"
- "directorio con dot files"
- "directorio con subdirs ignorados"
- "filtros include/exclude"
- "modo strict"
test_file_path: "python/functions/infra/scan_directory_test.py"
file_path: "python/functions/infra/scan_directory.py"
---
## Ejemplo
```python
from scan_directory import scan_directory
# Escanear directorio de documentos, solo PDF y Markdown
result = scan_directory(
"/data/proyecto",
supported_extensions={".pdf", ".md"},
ignore_dirs={"archive"},
exclude="*.tmp,drafts/",
strict=False,
)
print(f"Procesables: {len(result.processable)}")
print(f"No soportados: {len(result.unsupported)}")
for f in result.processable:
print(f" {f.rel_path}")
```
## Notas
Funcion impura: realiza I/O de sistema de archivos con `os.walk`.
**Directorios ignorados por defecto (`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`, `vendor`.
**Logica de include/exclude:**
- `include`: patrones glob separados por coma (ej: `"*.pdf,*.md"`). Si se provee, solo se incluyen archivos que coincidan con al menos un patron.
- `exclude`: patrones glob separados por coma. Si el patron termina con `/` es un prefijo de path relativo (ej: `"drafts/"`); sin `/` es un glob de nombre (ej: `"*.tmp"`).
**Modo strict:** si `strict=True` y hay archivos no soportados, lanza `ValueError` con la lista de archivos no soportados. Util para pipelines que requieren directorio 100% homogeneo.
**Orden de resultados:** `processable` y `unsupported` se ordenan por `rel_path` ascendente para salida determinista.
Los paths relativos en `ClassifiedFile.rel_path` siempre usan forward slashes (`/`) independientemente del OS.
+217
View File
@@ -0,0 +1,217 @@
"""scan_directory — recorre un arbol de directorios y clasifica cada archivo."""
import fnmatch
import os
import sys
from pathlib import Path
# Importar tipos cuando el modulo se carga desde su directorio o via PYTHONPATH
_HERE = Path(__file__).parent
_TYPES_INFRA = Path(__file__).parent.parent.parent / "types" / "infra"
for _p in [str(_HERE), str(_TYPES_INFRA)]:
if _p not in sys.path:
sys.path.insert(0, _p)
from classified_file import ClassifiedFile # noqa: E402
from directory_scan_result import DirectoryScanResult # noqa: E402
# Directorios ignorados por defecto
IGNORE_DIRS: set[str] = {
"__pycache__",
"node_modules",
".git",
".svn",
".hg",
"venv",
".venv",
"env",
".env",
".tox",
".nox",
".mypy_cache",
".pytest_cache",
".ruff_cache",
"dist",
"build",
".next",
".nuxt",
"target",
"vendor",
}
def 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:
"""Recorre un arbol de directorios y clasifica cada archivo como procesable o no soportado.
Util para validacion pre-importacion de directorios: identifica que archivos
podran procesarse y cuales seran ignorados antes de iniciar cualquier pipeline.
Args:
root: Path al directorio raiz a escanear.
supported_extensions: Conjunto de extensiones procesables (ej: {".pdf", ".md"}).
Si es None, todos los archivos no filtrados se marcan como "processable".
ignore_dirs: Nombres o paths relativos de directorios adicionales a ignorar.
Se suman a IGNORE_DIRS. Los paths relativos usan forward slashes.
include: Patrones glob separados por coma (ej: "*.pdf,*.md"). Si se provee,
solo se incluyen archivos que coincidan con al menos un patron.
exclude: Patrones glob separados por coma. Patrones con "/" final son prefijos
de path (ej: "drafts/"); sin "/" son globs de nombre (ej: "*.tmp").
strict: Si True, lanza ValueError si hay archivos no soportados al final.
Returns:
DirectoryScanResult con listas de archivos procesables, no soportados,
paths saltados y warnings.
Raises:
FileNotFoundError: Si root no existe.
NotADirectoryError: Si root no es un directorio.
ValueError: Si strict=True y hay archivos no soportados.
"""
root_path = Path(root).resolve()
if not root_path.exists():
raise FileNotFoundError(f"Directorio no encontrado: {root}")
if not root_path.is_dir():
raise NotADirectoryError(f"No es un directorio: {root}")
# Construir conjuntos de filtro
extra_ignore = ignore_dirs or set()
all_ignore = IGNORE_DIRS | extra_ignore
include_patterns: list[str] = (
[p.strip() for p in include.split(",") if p.strip()] if include else []
)
exclude_patterns: list[str] = (
[p.strip() for p in exclude.split(",") if p.strip()] if exclude else []
)
processable: list[ClassifiedFile] = []
unsupported: list[ClassifiedFile] = []
skipped: list[str] = []
warnings: list[str] = []
for dirpath, dirnames, filenames in os.walk(str(root_path), topdown=True):
dir_path = Path(dirpath)
rel_dir = dir_path.relative_to(root_path)
# Podar directorios (modificar in-place para que os.walk no los visite)
pruned: list[str] = []
kept: list[str] = []
for d in dirnames:
dir_abs = dir_path / d
rel_d = rel_dir / d
rel_d_str = rel_d.as_posix()
# Skip dot dirs
if d.startswith("."):
skipped.append(f"{dir_abs} (dot directory)")
pruned.append(d)
continue
# Skip symlinks
if dir_abs.is_symlink():
skipped.append(f"{dir_abs} (symlink)")
pruned.append(d)
continue
# Skip IGNORE_DIRS (por nombre o por path relativo)
if d in all_ignore or rel_d_str in all_ignore:
skipped.append(f"{dir_abs} (ignored directory)")
pruned.append(d)
continue
kept.append(d)
dirnames[:] = kept
# Procesar archivos
for filename in sorted(filenames):
file_abs = dir_path / filename
rel_file = (rel_dir / filename).as_posix()
# Skip dot files
if filename.startswith("."):
skipped.append(f"{file_abs} (dot file)")
continue
# Skip symlinks
if file_abs.is_symlink():
skipped.append(f"{file_abs} (symlink)")
continue
# Skip archivos vacios
try:
if file_abs.stat().st_size == 0:
skipped.append(f"{file_abs} (empty file)")
continue
except OSError as exc:
warnings.append(f"No se pudo leer {file_abs}: {exc}")
continue
# Aplicar filtro include (si hay patrones, debe coincidir con al menos uno)
if include_patterns:
if not any(fnmatch.fnmatch(filename, p) for p in include_patterns):
skipped.append(f"{file_abs} (no coincide con include)")
continue
# Aplicar filtro exclude
excluded = False
for pat in exclude_patterns:
if pat.endswith("/"):
# Es un prefijo de path relativo
prefix = pat # ej: "drafts/"
if rel_file.startswith(prefix):
excluded = True
break
else:
# Es un glob de nombre de archivo
if fnmatch.fnmatch(filename, pat):
excluded = True
break
if excluded:
skipped.append(f"{file_abs} (excluido por exclude)")
continue
# Clasificar por extension
ext = Path(filename).suffix.lower()
if supported_extensions is None or ext in supported_extensions:
classification = "processable"
else:
classification = "unsupported"
cf = ClassifiedFile(
path=str(file_abs),
rel_path=rel_file,
classification=classification,
)
if classification == "processable":
processable.append(cf)
else:
unsupported.append(cf)
# Ordenar por rel_path
processable.sort(key=lambda f: f.rel_path)
unsupported.sort(key=lambda f: f.rel_path)
result = DirectoryScanResult(
root=str(root_path),
processable=processable,
unsupported=unsupported,
skipped=skipped,
warnings=warnings,
)
if strict and unsupported:
unsupported_paths = [f.rel_path for f in unsupported]
raise ValueError(
f"strict=True: {len(unsupported)} archivos no soportados: {unsupported_paths}"
)
return result
@@ -0,0 +1,181 @@
"""Tests para scan_directory."""
import os
import sys
import tempfile
from pathlib import Path
# Asegurar que los modulos del mismo directorio y tipos se puedan importar
_HERE = Path(__file__).parent
_TYPES_INFRA = Path(__file__).parent.parent.parent / "types" / "infra"
for _p in [str(_HERE), str(_TYPES_INFRA)]:
if _p not in sys.path:
sys.path.insert(0, _p)
from scan_directory import scan_directory # noqa: E402
def _make_tree(base: Path, structure: dict) -> None:
"""Crea un arbol de archivos/dirs a partir de un dict {rel_path: content}."""
for rel, content in structure.items():
path = base / rel
path.parent.mkdir(parents=True, exist_ok=True)
if content is None:
path.mkdir(parents=True, exist_ok=True)
else:
path.write_text(content, encoding="utf-8")
# ---------------------------------------------------------------------------
# Test: directorio con mezcla de archivos
# ---------------------------------------------------------------------------
def test_directorio_con_mezcla_de_archivos():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
_make_tree(root, {
"report.pdf": "pdf content",
"notes.md": "# Notes",
"image.png": "png content",
"data.csv": "a,b,c",
})
result = scan_directory(str(root), supported_extensions={".pdf", ".md"})
rel_paths = [f.rel_path for f in result.processable]
assert "notes.md" in rel_paths, f"notes.md no en processable: {rel_paths}"
assert "report.pdf" in rel_paths, f"report.pdf no en processable: {rel_paths}"
unsup_paths = [f.rel_path for f in result.unsupported]
assert "image.png" in unsup_paths, f"image.png no en unsupported: {unsup_paths}"
assert "data.csv" in unsup_paths, f"data.csv no en unsupported: {unsup_paths}"
assert all(f.classification == "processable" for f in result.processable)
assert all(f.classification == "unsupported" for f in result.unsupported)
# ---------------------------------------------------------------------------
# Test: directorio con dot files
# ---------------------------------------------------------------------------
def test_directorio_con_dot_files():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
_make_tree(root, {
"visible.txt": "content",
".hidden": "hidden content",
".env": "SECRET=x",
})
result = scan_directory(str(root))
all_paths = [f.rel_path for f in result.processable + result.unsupported]
assert ".hidden" not in all_paths, f".hidden no deberia aparecer: {all_paths}"
assert ".env" not in all_paths, f".env no deberia aparecer: {all_paths}"
assert "visible.txt" in all_paths, f"visible.txt deberia aparecer: {all_paths}"
skipped_paths = " ".join(result.skipped)
assert ".hidden" in skipped_paths or ".env" in skipped_paths
# ---------------------------------------------------------------------------
# Test: directorio con subdirs ignorados
# ---------------------------------------------------------------------------
def test_directorio_con_subdirs_ignorados():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
_make_tree(root, {
"main.py": "print('hello')",
"__pycache__/module.pyc": "bytecode",
"node_modules/lib/index.js": "// js",
".git/config": "[core]",
"src/utils.py": "def f(): pass",
})
result = scan_directory(str(root))
all_rels = [f.rel_path for f in result.processable + result.unsupported]
# Archivos dentro de dirs ignorados no deben aparecer
assert not any("__pycache__" in r for r in all_rels), \
f"__pycache__ no deberia estar en resultados: {all_rels}"
assert not any("node_modules" in r for r in all_rels), \
f"node_modules no deberia estar en resultados: {all_rels}"
assert not any(".git" in r for r in all_rels), \
f".git no deberia estar en resultados: {all_rels}"
# Archivos fuera de dirs ignorados si deben aparecer
assert "main.py" in all_rels, f"main.py deberia estar: {all_rels}"
assert "src/utils.py" in all_rels, f"src/utils.py deberia estar: {all_rels}"
# ---------------------------------------------------------------------------
# Test: filtros include/exclude
# ---------------------------------------------------------------------------
def test_filtros_include_exclude():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
_make_tree(root, {
"report.pdf": "content",
"notes.md": "notes",
"image.png": "image",
"drafts/draft.md": "draft",
"temp.tmp": "tmp",
})
# Solo incluir .pdf y .md
result = scan_directory(str(root), include="*.pdf,*.md")
all_rels = [f.rel_path for f in result.processable + result.unsupported]
assert "image.png" not in all_rels, f"image.png no deberia incluirse: {all_rels}"
assert "temp.tmp" not in all_rels, f"temp.tmp no deberia incluirse: {all_rels}"
assert "report.pdf" in all_rels
assert "notes.md" in all_rels
# Excluir path prefix drafts/ y extension .tmp
result2 = scan_directory(str(root), exclude="drafts/,*.tmp")
all_rels2 = [f.rel_path for f in result2.processable + result2.unsupported]
assert "drafts/draft.md" not in all_rels2, \
f"drafts/draft.md no deberia incluirse: {all_rels2}"
assert "temp.tmp" not in all_rels2, f"temp.tmp no deberia incluirse: {all_rels2}"
assert "report.pdf" in all_rels2
# ---------------------------------------------------------------------------
# Test: modo strict
# ---------------------------------------------------------------------------
def test_modo_strict():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
_make_tree(root, {
"doc.pdf": "content",
"image.png": "image",
})
# strict=False no lanza error aunque haya unsupported
result = scan_directory(str(root), supported_extensions={".pdf"}, strict=False)
assert len(result.unsupported) == 1
# strict=True lanza ValueError
raised = False
try:
scan_directory(str(root), supported_extensions={".pdf"}, strict=True)
except ValueError:
raised = True
assert raised, "strict=True deberia lanzar ValueError cuando hay unsupported"
if __name__ == "__main__":
test_directorio_con_mezcla_de_archivos()
print("PASS: directorio con mezcla de archivos")
test_directorio_con_dot_files()
print("PASS: directorio con dot files")
test_directorio_con_subdirs_ignorados()
print("PASS: directorio con subdirs ignorados")
test_filtros_include_exclude()
print("PASS: filtros include/exclude")
test_modo_strict()
print("PASS: modo strict")
print("\nAll tests passed.")
+51
View File
@@ -0,0 +1,51 @@
---
name: setup_logger
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def setup_logger(name: str = 'app', log_dir: str = 'logs', level: int = logging.DEBUG) -> logging.Logger"
description: "Configura un logger con dual output: archivo con rotacion por tamano (DEBUG+, 10MB, 5 backups) y consola (INFO+). Crea log_dir si no existe. Idempotente: no duplica handlers si el logger ya esta configurado."
tags: [logging, logger, rotation, file, console, infra, debug]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [logging, logging.handlers, os, sys, datetime]
tested: true
tests:
- "logger se crea con 2 handlers"
- "segundo call no duplica handlers"
- "archivo se crea en log_dir"
- "get_logger retorna logger configurado"
- "logger level es debug"
test_file_path: "python/functions/infra/setup_logger_test.py"
file_path: "python/functions/infra/setup_logger.py"
---
## Ejemplo
```python
from setup_logger import setup_logger, get_logger
# Configurar al inicio de la aplicacion
logger = setup_logger(name="mi_app", log_dir="logs", level=logging.DEBUG)
logger.info("Aplicacion iniciada")
logger.debug("Detalle de debug")
# En modulos internos: obtener logger ya configurado
log = get_logger("mi_app")
log.warning("Algo inesperado ocurrio")
```
## Notas
Funcion impura: crea el directorio `log_dir` en disco y modifica el estado global del sistema de logging de Python.
El archivo de log tiene nombre `YYYY-MM-DD.log` segun la fecha de inicio. La rotacion es por tamano (10 MB), no por tiempo — por eso el nombre es fijo para cada dia de inicio de la aplicacion.
En Windows se reconfigura `sys.stdout` a UTF-8 para evitar mojibake con caracteres no-ASCII.
La funcion companion `get_logger` es util en modulos que no controlan la inicializacion: devuelve el logger si ya fue configurado, o lo crea con defaults.
+85
View File
@@ -0,0 +1,85 @@
"""Configuracion de logger con rotacion de archivo y salida a consola."""
import logging
import logging.handlers
import os
import sys
from datetime import datetime
def setup_logger(
name: str = "app",
log_dir: str = "logs",
level: int = logging.DEBUG,
) -> logging.Logger:
"""Configura un logger con dual output: archivo rotante y consola.
Crea el directorio de logs si no existe. El archivo usa nivel DEBUG con
formato detallado y rotacion diaria (maxBytes=10MB, backupCount=5).
La consola usa nivel INFO con formato simplificado. Es idempotente: si el
logger ya tiene handlers no se duplican.
Args:
name: Nombre del logger (identifica la instancia en el sistema de logging).
log_dir: Directorio donde se guardan los archivos de log.
level: Nivel minimo del logger principal (por defecto DEBUG).
Returns:
Logger configurado con handler de archivo y handler de consola.
"""
os.makedirs(log_dir, exist_ok=True)
logger = logging.getLogger(name)
logger.setLevel(level)
logger.propagate = False
# Idempotente: si ya tiene handlers no agregar mas
if logger.handlers:
return logger
fmt_detailed = logging.Formatter(
"[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s"
)
fmt_simple = logging.Formatter(
"[%(asctime)s] %(levelname)s: %(message)s"
)
# File handler con rotacion por tamano
log_filename = os.path.join(log_dir, f"{datetime.now():%Y-%m-%d}.log")
file_handler = logging.handlers.RotatingFileHandler(
log_filename,
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
encoding="utf-8",
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(fmt_detailed)
# Console handler
if sys.platform == "win32":
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except AttributeError:
pass
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(fmt_simple)
logger.addHandler(file_handler)
logger.addHandler(console_handler)
return logger
def get_logger(name: str = "app") -> logging.Logger:
"""Devuelve un logger existente o lo crea con setup_logger.
Args:
name: Nombre del logger.
Returns:
Logger configurado.
"""
logger = logging.getLogger(name)
return logger if logger.handlers else setup_logger(name)
@@ -0,0 +1,49 @@
"""Tests para setup_logger."""
import logging
import os
import tempfile
from setup_logger import get_logger, setup_logger
def test_logger_tiene_dos_handlers():
with tempfile.TemporaryDirectory() as log_dir:
logger = setup_logger(name="test_two_handlers", log_dir=log_dir)
assert len(logger.handlers) == 2
# limpiar para no contaminar otros tests
logger.handlers.clear()
def test_segundo_call_no_duplica_handlers():
with tempfile.TemporaryDirectory() as log_dir:
logger1 = setup_logger(name="test_idempotent", log_dir=log_dir)
handler_count_after_first = len(logger1.handlers)
logger2 = setup_logger(name="test_idempotent", log_dir=log_dir)
assert logger1 is logger2
assert len(logger2.handlers) == handler_count_after_first
logger1.handlers.clear()
def test_archivo_se_crea_en_log_dir():
with tempfile.TemporaryDirectory() as log_dir:
logger = setup_logger(name="test_file_created", log_dir=log_dir)
log_files = [f for f in os.listdir(log_dir) if f.endswith(".log")]
assert len(log_files) == 1
logger.handlers.clear()
def test_get_logger_retorna_logger_configurado():
with tempfile.TemporaryDirectory() as log_dir:
# Primero configurar para que get_logger encuentre handlers
setup_logger(name="test_get_logger", log_dir=log_dir)
logger = get_logger(name="test_get_logger")
assert len(logger.handlers) == 2
logger.handlers.clear()
def test_logger_level_es_debug():
with tempfile.TemporaryDirectory() as log_dir:
logger = setup_logger(name="test_level_debug", log_dir=log_dir, level=logging.DEBUG)
assert logger.level == logging.DEBUG
logger.handlers.clear()
+43
View File
@@ -0,0 +1,43 @@
---
name: agent_action
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class AgentAction:
round_num: int
timestamp: str
platform: str
agent_id: int
agent_name: str
action_type: str
action_args: dict = field(default_factory=dict)
result: str | None = None
success: bool = True
description: "Registro de una accion individual de un agente en una simulacion multi-agente."
tags: [simulation, agent, action, multi-agent, event]
uses_types: []
file_path: "python/types/core/agent_action.py"
---
## Ejemplo
```python
action = AgentAction(
round_num=1,
timestamp="2024-01-01T12:00:00Z",
platform="twitter",
agent_id=42,
agent_name="bot_alpha",
action_type="CREATE_POST",
action_args={"content": "Hello world"},
success=True,
)
print(action.to_dict())
```
## Notas
Tipo producto — todos los campos obligatorios estan siempre presentes. `action_args` es un dict abierto para que el caller especifique los parametros sin restricciones de schema. `result` es opcional porque puede no haber sido resuelto aun en el momento de creacion.
+48
View File
@@ -0,0 +1,48 @@
"""Registro de una accion individual de un agente en una simulacion multi-agente."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class AgentAction:
"""Registro de una accion individual de un agente en una simulacion multi-agente.
Captura que hizo el agente, cuando, en que plataforma, y si fue exitoso.
Attributes:
round_num: Ronda de la simulacion en la que ocurrio la accion.
timestamp: Timestamp ISO 8601 del momento de la accion.
platform: Plataforma donde ocurrio ("twitter", "reddit", o custom).
agent_id: ID numerico del agente.
agent_name: Nombre del agente.
action_type: Tipo de accion ("CREATE_POST", "LIKE_POST", "COMMENT", etc.).
action_args: Parametros especificos de la accion.
result: Resultado de la accion (None si aun no hay resultado).
success: Si la accion fue exitosa.
"""
round_num: int
timestamp: str
platform: str
agent_id: int
agent_name: str
action_type: str
action_args: dict = field(default_factory=dict)
result: str | None = None
success: bool = True
def to_dict(self) -> dict:
"""Convierte la accion a un diccionario serializable."""
return {
"round_num": self.round_num,
"timestamp": self.timestamp,
"platform": self.platform,
"agent_id": self.agent_id,
"agent_name": self.agent_name,
"action_type": self.action_type,
"action_args": self.action_args,
"result": self.result,
"success": self.success,
}
+33
View File
@@ -0,0 +1,33 @@
---
name: base_parser
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
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
description: "Protocolo abstracto que todo parser del registry debe implementar. Define la interfaz minima para parsers extensibles: extensiones soportadas, parse desde path y parse desde contenido en memoria. El ParserRegistry usa esta interfaz para despachar al parser correcto segun extension de archivo."
tags: [parser, protocol, abstract, extensible, plugin]
uses_types: [parse_result_py_core]
file_path: "python/types/core/base_parser.py"
---
## Notas
Clase base abstracta (ABC) con tres metodos obligatorios y un helper concreto `can_parse`. Los parsers concretos heredan de BaseParser e implementan los tres metodos abstractos.
El metodo `can_parse` es concreto — verifica la extension del archivo contra `supported_extensions`. No es necesario sobreescribirlo salvo casos especiales.
Disenado para composicion con ParserRegistry: el registry registra instancias de BaseParser y las resuelve automaticamente por extension de archivo.
+76
View File
@@ -0,0 +1,76 @@
"""BaseParser — protocolo abstracto para parsers del registry."""
from __future__ import annotations
from abc import ABC, abstractmethod
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from parse_result import ParseResult
class BaseParser(ABC):
"""Protocolo abstracto que todo parser del registry debe implementar.
Define la interfaz minima para parsers extensibles: soportar extensiones
de archivo, parsear desde path o desde contenido en memoria. El
ParserRegistry usa esta interfaz para despachar parsing al parser correcto.
Attributes:
supported_extensions: Lista de extensiones que este parser maneja
(ej: [".md", ".markdown"]).
"""
@property
@abstractmethod
def supported_extensions(self) -> list[str]:
"""Lista de extensiones soportadas (con punto, lowercase).
Returns:
Lista de strings como [".md", ".markdown"].
"""
...
@abstractmethod
async def parse(self, source: str | Path, **kwargs) -> "ParseResult":
"""Parsea un archivo desde su path o URL.
Args:
source: Path al archivo, directorio o URL a parsear.
**kwargs: Argumentos adicionales especificos del parser.
Returns:
ParseResult con el arbol de nodos del documento.
"""
...
@abstractmethod
async def parse_content(
self,
content: str,
source_path: str | None = None,
**kwargs,
) -> "ParseResult":
"""Parsea un string de contenido directamente en memoria.
Args:
content: Contenido del documento como string.
source_path: Path original opcional, solo para metadata.
**kwargs: Argumentos adicionales especificos del parser.
Returns:
ParseResult con el arbol de nodos del documento.
"""
...
def can_parse(self, path: str) -> bool:
"""Indica si este parser puede manejar el archivo dado.
Args:
path: Path del archivo a verificar.
Returns:
True si la extension del archivo esta en supported_extensions.
"""
return Path(path).suffix.lower() in self.supported_extensions
+84
View File
@@ -0,0 +1,84 @@
---
name: code_entity
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@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)
description: "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."
tags: [ast, code, parsing, tree-sitter, symbol, entity, core]
uses_types: []
file_path: "python/types/core/code_entity.py"
---
## Propiedades
- `line_count -> int` (property) — numero de lineas de la entidad: `end_line - start_line + 1`
- `has_docstring -> bool` (property) — True si `docstring` es no nulo y no vacio
## Metodos
- `to_dict() -> dict` — serializa la entidad de forma recursiva, incluyendo `children`
- `from_dict(data: dict) -> CodeEntity` (classmethod) — deserializa desde un dict, reconstruyendo el arbol de children
## Valores esperados
| Campo | Valores |
|---|---|
| `kind` | `"function"`, `"class"`, `"method"`, `"interface"`, `"type"`, `"struct"`, `"trait"` |
| `language` | `"python"`, `"go"`, `"typescript"`, `"rust"`, `"java"`, `"cpp"` |
## Ejemplo
```python
from code_entity import CodeEntity
# Funcion standalone
fn = CodeEntity(
kind="function",
name="parse_file",
start_line=10,
end_line=35,
signature="def parse_file(path: str) -> list[CodeEntity]:",
docstring="Parsea un archivo de codigo y retorna sus entidades.",
language="python",
)
print(fn.line_count) # 26
print(fn.has_docstring) # True
# Clase con metodos como children
cls = CodeEntity(
kind="class",
name="Parser",
start_line=1,
end_line=80,
signature="class Parser:",
docstring=None,
language="python",
children=[fn],
)
data = cls.to_dict()
restored = CodeEntity.from_dict(data)
assert restored.name == "Parser"
assert restored.children[0].name == "parse_file"
```
## Notas
- `signature` incluye decoradores/annotations en Python, generic params en TypeScript/Go, visibility modifiers en Java/Rust. Es todo el texto desde el inicio de la definicion hasta el inicio del body (excluyendo `:`).
- `docstring` es el primer string literal hijo en Python, el comentario `///` o `/** */` previo en otros lenguajes. Puede ser None si la entidad no esta documentada.
- `children` solo se usa para metodos dentro de clases/structs/traits/interfaces. Las funciones standalone tienen `children = []`.
- El arbol de children es un nivel de profundidad en la mayoria de los casos (clase -> metodos). Traits con associated types pueden tener mas niveles.
- Tipo pensado como resultado de `parse_code_ast()`. Util para indexar codigo, generar skeletons, extraer candidatos de repos externos y navegacion (jump to definition).
+61
View File
@@ -0,0 +1,61 @@
"""CodeEntity — entidad de codigo extraida por analisis AST."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class CodeEntity:
"""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.
"""
kind: str
name: str
start_line: int
end_line: int
signature: str
docstring: str | None
language: str
children: list[CodeEntity] = field(default_factory=list)
@property
def line_count(self) -> int:
"""Numero de lineas que ocupa la entidad."""
return self.end_line - self.start_line + 1
@property
def has_docstring(self) -> bool:
"""True si la entidad tiene docstring o comentario documentado."""
return self.docstring is not None and len(self.docstring.strip()) > 0
def to_dict(self) -> dict:
"""Serializa la entidad a un diccionario de forma recursiva."""
return {
"kind": self.kind,
"name": self.name,
"start_line": self.start_line,
"end_line": self.end_line,
"signature": self.signature,
"docstring": self.docstring,
"language": self.language,
"children": [child.to_dict() for child in self.children],
}
@classmethod
def from_dict(cls, data: dict) -> CodeEntity:
"""Deserializa una entidad desde un diccionario (recursivo)."""
children = [cls.from_dict(c) for c in data.get("children", [])]
return cls(
kind=data["kind"],
name=data["name"],
start_line=data["start_line"],
end_line=data["end_line"],
signature=data["signature"],
docstring=data.get("docstring"),
language=data["language"],
children=children,
)
+39
View File
@@ -0,0 +1,39 @@
---
name: context
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class Context:
id: str
uri: str
parent_uri: str | None = None
is_leaf: bool = False
abstract: str = ""
context_type: str = "resource"
category: str = ""
level: int | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
active_count: int = 0
related_uri: list[str] = field(default_factory=list)
meta: dict[str, Any] = field(default_factory=dict)
description: "Modelo unificado de contexto que representa cualquier recurso (documento, memoria, skill) indexable y buscable. Combina metadata, jerarquia via URI, conteo de accesos para hotness scoring, y relaciones entre contextos."
tags: [context, rag, vector-db, retrieval, memory, resource, hierarchy, hotness]
uses_types: [context_level_py_core, resource_content_type_py_core]
file_path: "python/types/core/context.py"
---
## Notas
El campo `uri` sigue un esquema tipo filesystem: `viking://resources/docs/readme.md`. El campo `parent_uri` permite reconstruir la jerarquia de directorios sin necesidad de un grafo explicito.
El campo `active_count` se incrementa cada vez que el contexto se recupera — usado por `hotness_score()` para priorizar resultados frecuentes sobre resultados menos accedidos.
El campo `level` referencia los valores de `ContextLevel` (0=ABSTRACT, 1=OVERVIEW, 2=DETAIL). Se almacena como `int | None` para evitar acoplar el tipo a la importacion del enum.
El campo `related_uri` permite modelar grafos de relaciones entre contextos (links, referencias, dependencias) sin necesidad de una tabla de relaciones separada.
Los metodos `to_dict()` y `from_dict()` son inversos entre si: `Context.from_dict(ctx.to_dict()) == ctx` (asumiendo que los timestamps son objetos datetime, no strings).
+102
View File
@@ -0,0 +1,102 @@
"""Context — modelo unificado de contexto indexable y buscable."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
@dataclass
class Context:
"""Modelo unificado de contexto que representa cualquier recurso indexable.
Representa documentos, memorias o skills almacenados en una base de datos
vectorial. Combina metadata, jerarquia de directorios (via URI), conteo de
accesos para hotness scoring, y relaciones entre contextos.
Attributes:
id: UUID unico del contexto.
uri: Identificador unico del recurso, estilo filesystem.
Ejemplo: ``viking://resources/docs/readme.md``
parent_uri: URI del nodo padre (directorio que lo contiene).
None si es la raiz.
is_leaf: True si es un nodo hoja (archivo), False si es directorio.
abstract: Resumen corto del contenido (L0, ~256 chars).
context_type: Categoria semantica del contexto.
Valores tipicos: "memory", "resource", "skill".
category: Subcategoria libre para clasificacion adicional.
level: Nivel de detalle (ContextLevel: 0=ABSTRACT, 1=OVERVIEW, 2=DETAIL).
None si no se ha asignado nivel.
created_at: Timestamp de creacion (UTC). None si desconocido.
updated_at: Timestamp de ultima actualizacion (UTC). None si desconocido.
active_count: Numero de veces que el contexto fue recuperado.
Usado por hotness_score() para priorizar resultados frecuentes.
related_uri: Lista de URIs de contextos relacionados. Permite grafos de relaciones.
meta: Datos arbitrarios adicionales (parametros, etiquetas, etc.).
"""
id: str
uri: str
parent_uri: str | None = None
is_leaf: bool = False
abstract: str = ""
context_type: str = "resource"
category: str = ""
level: int | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
active_count: int = 0
related_uri: list[str] = field(default_factory=list)
meta: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict:
"""Serializa el contexto a dict con timestamps en formato ISO 8601.
Returns:
dict con todos los campos listos para JSON o almacenamiento en BD.
"""
return {
"id": self.id,
"uri": self.uri,
"parent_uri": self.parent_uri,
"is_leaf": self.is_leaf,
"abstract": self.abstract,
"context_type": self.context_type,
"category": self.category,
"level": self.level,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"active_count": self.active_count,
"related_uri": self.related_uri,
"meta": self.meta,
}
@classmethod
def from_dict(cls, data: dict) -> "Context":
"""Deserializa un Context desde un dict (inverso de to_dict).
Args:
data: dict con los campos del contexto. Los timestamps pueden ser
strings ISO 8601 o None.
Returns:
Instancia de Context con los valores del dict.
"""
created_at = data.get("created_at")
updated_at = data.get("updated_at")
return cls(
id=data["id"],
uri=data["uri"],
parent_uri=data.get("parent_uri"),
is_leaf=data.get("is_leaf", False),
abstract=data.get("abstract", ""),
context_type=data.get("context_type", "resource"),
category=data.get("category", ""),
level=data.get("level"),
created_at=datetime.fromisoformat(created_at) if isinstance(created_at, str) else created_at,
updated_at=datetime.fromisoformat(updated_at) if isinstance(updated_at, str) else updated_at,
active_count=data.get("active_count", 0),
related_uri=data.get("related_uri", []),
meta=data.get("meta", {}),
)
+22
View File
@@ -0,0 +1,22 @@
---
name: context_level
lang: py
domain: core
version: "1.0.0"
algebraic: sum
definition: |
class ContextLevel(int, Enum):
ABSTRACT = 0
OVERVIEW = 1
DETAIL = 2
description: "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. Hereda de int para comparacion ordinal directa."
tags: [context, retrieval, rag, vector-db, hierarchy, level, indexing]
uses_types: []
file_path: "python/types/core/context_level.py"
---
## Notas
Hereda de `int` ademas de `Enum` para que las variantes sean directamente comparables y ordenables: `ContextLevel.ABSTRACT < ContextLevel.DETAIL`. Util en logica de retrieval que filtra por nivel minimo requerido.
Los valores enteros mapean a la granularidad del contenido: 0 (mas comprimido) → 2 (mas detallado). El retriever implementa una estrategia de cascada: busca primero en L0, si la similitud supera el umbral devuelve el resultado; si no, sube a L1 o L2.
+20
View File
@@ -0,0 +1,20 @@
"""ContextLevel — nivel de detalle de un contexto para retrieval jerarquico."""
from enum import Enum
class ContextLevel(int, Enum):
"""Nivel de detalle de un contexto para indexacion vectorial y retrieval jerarquico.
El retriever primero busca en ABSTRACT (rapido, barato), luego refina con
OVERVIEW o DETAIL segun la precision requerida.
Variantes:
ABSTRACT: L0 — resumen corto (~256 chars). Maximo rendimiento, minimo costo.
OVERVIEW: L1 — overview del contenido (~4000 chars). Balance costo/precision.
DETAIL: L2 — contenido completo. Maximo detalle, mayor costo de retrieval.
"""
ABSTRACT = 0
OVERVIEW = 1
DETAIL = 2
+28
View File
@@ -0,0 +1,28 @@
---
name: context_part
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class ContextPart:
uri: str = ""
context_type: str = "memory"
abstract: str = ""
type: str = "context"
description: "Referencia a un contexto externo (memoria, recurso o skill) dentro de un mensaje multipart. Permite enriquecer mensajes con referencias tipadas a contextos recuperados por el agente."
tags: [message, part, context, memory, resource, skill, multipart, llm, rag]
uses_types: []
file_path: "python/types/core/context_part.py"
---
## Notas
`context_type` puede ser "memory" (recuperado de memoria a largo plazo), "resource" (documento o fichero externo), o "skill" (capacidad o herramienta disponible).
`uri` identifica el recurso de forma unica — puede ser un ID de memoria, una URL, o un nombre de skill.
`abstract` contiene un resumen legible del contexto, util para estimar tokens sin cargar el contenido completo.
El campo `type` actua como discriminador literal en el tipo suma `Part`. Siempre vale `"context"`.
+20
View File
@@ -0,0 +1,20 @@
"""ContextPart — referencia a un contexto externo en un mensaje multipart."""
from dataclasses import dataclass, field
@dataclass
class ContextPart:
"""Referencia a un contexto externo (memoria, recurso o skill) en un mensaje multipart.
Attributes:
uri: URI que identifica el contexto referenciado.
context_type: Tipo de contexto: "memory" | "resource" | "skill".
abstract: Resumen legible del contenido del contexto.
type: Discriminador literal, siempre "context".
"""
uri: str = ""
context_type: str = "memory"
abstract: str = ""
type: str = field(default="context", init=True)
+22
View File
@@ -0,0 +1,22 @@
---
name: context_type
lang: py
domain: core
version: "1.0.0"
algebraic: sum
definition: |
class ContextType(str, Enum):
MEMORY = "memory"
RESOURCE = "resource"
SKILL = "skill"
description: "Tipo de contexto para busqueda/retrieval. Permite filtrar resultados por categoria: memoria de sesion, recursos indexados, o skills/capacidades."
tags: [retrieval, context, enum, rag, search, filter]
uses_types: []
file_path: "python/types/core/context_type.py"
---
## Notas
Hereda de `str` y `Enum` para que las variantes sean directamente serializables como strings JSON sin necesidad de llamar a `.value`. Esto facilita la interoperabilidad con APIs y almacenamiento.
Las tres categorias reflejan la arquitectura tipica de sistemas RAG con memoria de agente: memorias de conversacion, documentos/recursos externos, y skills o procedimientos.
+19
View File
@@ -0,0 +1,19 @@
"""ContextType — tipo de contexto para busqueda/retrieval."""
from enum import Enum
class ContextType(str, Enum):
"""Tipo de contexto para busqueda/retrieval.
Permite filtrar resultados por categoria al construir queries tipadas.
Variants:
MEMORY: Contexto de memoria (conversaciones, historial de sesion).
RESOURCE: Contexto de recurso (documentos, archivos, URIs indexados).
SKILL: Contexto de skill (instrucciones, capacidades, procedimientos).
"""
MEMORY = "memory"
RESOURCE = "resource"
SKILL = "skill"
+47
View File
@@ -0,0 +1,47 @@
---
name: entity_node
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class EntityNode:
uuid: str
name: str
labels: list[str] = field(default_factory=list)
summary: str = ""
attributes: dict = field(default_factory=dict)
related_edges: list[dict] = field(default_factory=list)
related_nodes: list[dict] = field(default_factory=list)
description: "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."
tags: [entity, graph, knowledge-graph, node, core]
uses_types: []
file_path: "python/types/core/entity_node.py"
---
## Metodos
- `to_dict() -> dict` — serializa el nodo a un diccionario con todos sus campos.
- `get_entity_type() -> str | None` — retorna el primer label que no sea "Entity", o None si todos son genericos.
## Ejemplo
```python
from entity_node import EntityNode
node = EntityNode(
uuid="abc-123",
name="Alan Turing",
labels=["Person", "Professor"],
summary="Matematico y pionero de la computacion.",
attributes={"born": 1912, "nationality": "British"},
)
print(node.get_entity_type()) # "Person"
print(node.to_dict())
```
## Notas
El campo `labels` puede contener multiples etiquetas de tipo (e.g., ["Person", "Professor"]). `get_entity_type()` retorna el primer label no-generico para usarlo como tipo principal de la entidad. Los campos `related_edges` y `related_nodes` almacenan relaciones como dicts crudos para maxima flexibilidad sin acoplamiento a un schema especifico.
+39
View File
@@ -0,0 +1,39 @@
"""EntityNode — nodo de entidad extraido de un knowledge graph."""
from dataclasses import dataclass, field
@dataclass
class EntityNode:
"""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.
"""
uuid: str
name: str
labels: list[str] = field(default_factory=list)
summary: str = ""
attributes: dict = field(default_factory=dict)
related_edges: list[dict] = field(default_factory=list)
related_nodes: list[dict] = field(default_factory=list)
def to_dict(self) -> dict:
"""Serializa el nodo a un diccionario."""
return {
"uuid": self.uuid,
"name": self.name,
"labels": self.labels,
"summary": self.summary,
"attributes": self.attributes,
"related_edges": self.related_edges,
"related_nodes": self.related_nodes,
}
def get_entity_type(self) -> str | None:
"""Retorna el primer label que no sea generico ("Entity"), o None."""
for label in self.labels:
if label != "Entity":
return label
return None
+25
View File
@@ -0,0 +1,25 @@
---
name: field_type
lang: py
domain: core
version: "1.0.0"
algebraic: sum
definition: |
class FieldType(str, Enum):
STRING = "string"
INTEGER = "integer"
FLOAT = "float"
BOOLEAN = "boolean"
LIST = "list"
DICT = "dict"
DATETIME = "datetime"
description: "Tipo de dato de un campo de memoria. Enum sum type con siete variantes que cubren los tipos primitivos y compuestos mas comunes para campos de MemoryField."
tags: [memory, field, type, enum, schema]
uses_types: []
file_path: "python/types/core/field_type.py"
---
## Notas
Hereda de `str` para que los valores sean directamente serializables a JSON sin conversion adicional.
Usado por `MemoryField` para declarar el tipo de dato esperado en cada campo de un schema de memoria.
+25
View File
@@ -0,0 +1,25 @@
"""FieldType — tipo de dato de un campo de memoria."""
from enum import Enum
class FieldType(str, Enum):
"""Tipo de dato de un campo de memoria.
Valores posibles:
STRING — cadena de texto.
INTEGER — numero entero.
FLOAT — numero de punto flotante.
BOOLEAN — valor booleano.
LIST — lista de valores.
DICT — diccionario clave-valor.
DATETIME — fecha y hora.
"""
STRING = "string"
INTEGER = "integer"
FLOAT = "float"
BOOLEAN = "boolean"
LIST = "list"
DICT = "dict"
DATETIME = "datetime"
+48
View File
@@ -0,0 +1,48 @@
---
name: filtered_entities
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class FilteredEntities:
entities: list[EntityNode]
entity_types: set[str]
total_count: int
filtered_count: int
description: "Resultado de filtrar entidades de un knowledge graph por tipos definidos. Captura tanto las entidades filtradas como los conteos para reporting."
tags: [entity, graph, knowledge-graph, filter, core]
uses_types: [entity_node_py_core]
file_path: "python/types/core/filtered_entities.py"
---
## Metodos
- `to_dict() -> dict` — serializa el resultado a un diccionario, incluyendo cada entidad serializada con `EntityNode.to_dict()`.
## Ejemplo
```python
from entity_node import EntityNode
from filtered_entities import FilteredEntities
nodes = [
EntityNode(uuid="1", name="Alan Turing", labels=["Person"]),
EntityNode(uuid="2", name="MIT", labels=["Organization"]),
]
result = FilteredEntities(
entities=nodes,
entity_types={"Person", "Organization"},
total_count=10,
filtered_count=2,
)
print(result.filtered_count) # 2
print(result.to_dict())
```
## Notas
El campo `entity_types` es un `set[str]` que contiene los tipos unicos encontrados entre las entidades filtradas. `total_count` refleja el total antes de aplicar el filtro y `filtered_count` el total despues, permitiendo calcular cuantas entidades fueron descartadas. Depende de `EntityNode` para la lista de entidades.
+28
View File
@@ -0,0 +1,28 @@
"""FilteredEntities — resultado de filtrar entidades de un knowledge graph por tipos."""
from dataclasses import dataclass, field
from entity_node import EntityNode
@dataclass
class FilteredEntities:
"""Resultado de filtrar entidades de un graph por tipos definidos.
Captura tanto las entidades filtradas como los conteos para reporting.
El total_count vs filtered_count muestra cuantas entidades se descartaron.
"""
entities: list[EntityNode]
entity_types: set[str]
total_count: int
filtered_count: int
def to_dict(self) -> dict:
"""Serializa el resultado a un diccionario."""
return {
"entities": [e.to_dict() for e in self.entities],
"entity_types": list(self.entity_types),
"total_count": self.total_count,
"filtered_count": self.filtered_count,
}
+30
View File
@@ -0,0 +1,30 @@
---
name: find_result
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@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
description: "Resultado final de una busqueda completa, agrupado por tipo de contexto. Opcionalmente incluye provenance (traza de busqueda) para observabilidad."
tags: [retrieval, result, rag, search, find, aggregation]
uses_types: [matched_context_py_core, query_plan_py_core, query_result_py_core]
file_path: "python/types/core/find_result.py"
---
## Notas
`__post_init__` calcula `total` automaticamente al construir la instancia. Si se modifica `memories`, `resources` o `skills` despues de la construccion, `total` no se actualiza automaticamente — se debe reconstruir o actualizar manualmente.
`__iter__` permite iterar sobre todos los contextos en orden: memories → resources → skills. Facilita patrones como `for ctx in result: process(ctx)` sin necesidad de concatenar las listas.
`to_dict(include_provenance=True)` incluye el query_plan y un resumen de los query_results. El provenance es util para debugging y observabilidad pero puede ser verboso — por eso es opt-in.
`from_dict` reconstruye el resultado desde un dict serializado. Los campos de provenance (query_plan, query_results) no se reconstruyen — quedan como None. Si se necesita provenance en la deserializacion, hay que reconstruirlos separadamente.
+138
View File
@@ -0,0 +1,138 @@
"""FindResult — resultado final de una busqueda completa agrupado por tipo de contexto."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterator
from matched_context import MatchedContext
from query_plan import QueryPlan
from query_result import QueryResult
@dataclass
class FindResult:
"""Resultado final de una busqueda completa, agrupado por tipo de contexto.
Opcionalmente incluye provenance (traza de busqueda) para observabilidad.
Attributes:
memories: Contextos encontrados de tipo MEMORY.
resources: Contextos encontrados de tipo RESOURCE.
skills: Contextos encontrados de tipo SKILL.
query_plan: Plan de busqueda que genero estos resultados (provenance).
query_results: Resultados individuales por TypedQuery (provenance).
total: Total de contextos encontrados (calculado en __post_init__).
"""
memories: list[MatchedContext] = field(default_factory=list)
resources: list[MatchedContext] = field(default_factory=list)
skills: list[MatchedContext] = field(default_factory=list)
query_plan: QueryPlan | None = None
query_results: list[QueryResult] | None = None
total: int = 0
def __post_init__(self) -> None:
"""Calcula el total de contextos encontrados."""
self.total = len(self.memories) + len(self.resources) + len(self.skills)
def __iter__(self) -> Iterator[MatchedContext]:
"""Itera sobre todos los MatchedContext (memories + resources + skills)."""
yield from self.memories
yield from self.resources
yield from self.skills
def to_dict(self, include_provenance: bool = False) -> dict:
"""Serializa el resultado a dict.
Args:
include_provenance: Si True, incluye query_plan y query_results en el output.
Returns:
dict con memories, resources, skills y total. Opcionalmente con provenance.
"""
def _context_to_dict(ctx: MatchedContext) -> dict:
return {
"uri": ctx.uri,
"context_type": ctx.context_type.value,
"level": ctx.level,
"abstract": ctx.abstract,
"overview": ctx.overview,
"category": ctx.category,
"score": ctx.score,
"match_reason": ctx.match_reason,
"relations": [
{"uri": r.uri, "abstract": r.abstract} for r in ctx.relations
],
}
result: dict = {
"memories": [_context_to_dict(c) for c in self.memories],
"resources": [_context_to_dict(c) for c in self.resources],
"skills": [_context_to_dict(c) for c in self.skills],
"total": self.total,
}
if include_provenance and self.query_plan is not None:
result["query_plan"] = {
"session_context": self.query_plan.session_context,
"reasoning": self.query_plan.reasoning,
"queries": [
{
"query": q.query,
"context_type": q.context_type.value if q.context_type else None,
"intent": q.intent,
"priority": q.priority,
"target_directories": q.target_directories,
}
for q in self.query_plan.queries
],
}
if include_provenance and self.query_results is not None:
result["query_results"] = [
{
"query": qr.query.query,
"matched_count": len(qr.matched_contexts),
"searched_directories": qr.searched_directories,
}
for qr in self.query_results
]
return result
@classmethod
def from_dict(cls, data: dict) -> FindResult:
"""Deserializa un FindResult desde un dict (tipicamente JSON parseado).
Args:
data: dict con estructura equivalente a la producida por to_dict().
Returns:
FindResult reconstruido. Los campos de provenance se omiten (None).
"""
from context_type import ContextType
from related_context import RelatedContext
def _dict_to_context(d: dict) -> MatchedContext:
return MatchedContext(
uri=d["uri"],
context_type=ContextType(d["context_type"]),
level=d.get("level", 2),
abstract=d.get("abstract", ""),
overview=d.get("overview"),
category=d.get("category", ""),
score=d.get("score", 0.0),
match_reason=d.get("match_reason", ""),
relations=[
RelatedContext(uri=r["uri"], abstract=r.get("abstract", ""))
for r in d.get("relations", [])
],
)
return cls(
memories=[_dict_to_context(c) for c in data.get("memories", [])],
resources=[_dict_to_context(c) for c in data.get("resources", [])],
skills=[_dict_to_context(c) for c in data.get("skills", [])],
)
+33
View File
@@ -0,0 +1,33 @@
---
name: matched_context
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class MatchedContext:
uri: str
context_type: ContextType
level: int = 2
abstract: str = ""
overview: str | None = None
category: str = ""
score: float = 0.0
match_reason: str = ""
relations: list[RelatedContext] = field(default_factory=list)
description: "Contexto encontrado por el retriever con su score de relevancia, nivel de detalle, y contextos relacionados."
tags: [retrieval, context, rag, match, score, result]
uses_types: [context_type_py_core, related_context_py_core]
file_path: "python/types/core/matched_context.py"
---
## Notas
El campo `level` define cuanto contenido fue cargado: 0 solo tiene `abstract`, 1 tiene `abstract` + `overview`, 2 tiene el contenido completo. El retriever puede usar niveles bajos para hacer un primer filtrado barato y luego cargar el nivel 2 solo para los candidatos seleccionados.
`score` en rango 0.0-1.0 donde 1.0 es maxima relevancia. El umbral de inclusion depende de la estrategia del retriever (ver `ScoreDistribution` para calcular umbrales adaptativos).
`match_reason` es texto libre generado por el LLM o el retriever para explicar la decision. Util para debugging y para mostrar al usuario por que se incluyo este contexto.
`relations` permite construir grafos de conocimiento ligeros sin un graph DB completo — cada contexto conoce sus vecinos directos.
+38
View File
@@ -0,0 +1,38 @@
"""MatchedContext — contexto encontrado por el retriever con score y relaciones."""
from __future__ import annotations
from dataclasses import dataclass, field
from context_type import ContextType
from related_context import RelatedContext
@dataclass
class MatchedContext:
"""Contexto encontrado por el retriever con score de relevancia y relaciones.
Modela un resultado de busqueda con niveles de detalle progresivos:
abstract (minimo), overview (resumen), y detail (contenido completo).
Attributes:
uri: Identificador unico del contexto.
context_type: Categoria del contexto (MEMORY, RESOURCE, SKILL).
level: Nivel de detalle cargado: 0=abstract, 1=overview, 2=detail.
abstract: Resumen breve siempre disponible.
overview: Resumen extendido, disponible en level >= 1.
category: Subcategoria libre dentro del context_type.
score: Score de relevancia 0.0-1.0.
match_reason: Explicacion textual del por que este contexto fue seleccionado.
relations: Contextos relacionados (referencias cruzadas).
"""
uri: str
context_type: ContextType
level: int = 2
abstract: str = ""
overview: str | None = None
category: str = ""
score: float = 0.0
match_reason: str = ""
relations: list[RelatedContext] = field(default_factory=list)
+57
View File
@@ -0,0 +1,57 @@
---
name: memory_data
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class MemoryData:
memory_type: str
uri: str | None = None
fields: dict[str, Any] = field(default_factory=dict)
abstract: str | None = None
overview: str | None = None
content: str | None = None
name: str | None = None
tags: list[str] = field(default_factory=list)
created_at: datetime | None = None
updated_at: datetime | None = None
description: "Instancia concreta de un dato de memoria de agente. Los campos son dinamicos (dict) validados contra MemoryTypeSchema. Soporta 3 niveles de contenido (L0 abstract, L1 overview, L2 content) para retrieval jerarquico eficiente."
tags: [memory, data, agent, dataclass, retrieval, hierarchical]
uses_types: [memory_type_schema_py_core]
file_path: "python/types/core/memory_data.py"
---
## Notas
El campo `fields` es un dict dinamico cuyas claves deben corresponder a los `name` de los `MemoryField` del `MemoryTypeSchema` asociado. La validacion contra el schema es responsabilidad del sistema que consume este tipo.
Los 3 niveles de contenido permiten retrieval jerarquico:
- `abstract` (L0): una linea, para mostrar en listas o contexto muy limitado.
- `overview` (L1): parrafo, para contexto medio sin cargar el contenido completo.
- `content` (L2): sin limite, el dato completo serializado.
`uri` identifica la instancia para operaciones de update. Si es `None`, se trata como nueva insercion.
Metodos auxiliares `get_field` y `set_field` proveen acceso tipado al dict `fields`.
## Ejemplo
```python
entry = MemoryData(
memory_type="profile",
uri="profile/lucasg",
name="Lucas G",
abstract="Desarrollador senior con foco en sistemas distribuidos",
fields={
"username": "lucasg",
"role": "senior engineer",
"interaction_count": 42,
},
tags=["active", "admin"],
)
entry.set_field("role", "tech lead")
role = entry.get_field("role") # "tech lead"
```
+62
View File
@@ -0,0 +1,62 @@
"""MemoryData — instancia concreta de un dato de memoria de agente."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
@dataclass
class MemoryData:
"""Instancia concreta de un dato de memoria.
Los campos son dinamicos (dict) y se validan contra el MemoryTypeSchema
correspondiente. Soporta 3 niveles de contenido para retrieval jerarquico:
L0 abstract — resumen corto (una linea).
L1 overview — resumen medio (parrafo).
L2 content — contenido completo (sin limite).
Attributes:
memory_type: tipo de memoria al que pertenece esta instancia.
uri: URI para identificar y localizar la instancia en updates.
fields: datos dinamicos del schema correspondiente.
abstract: L0 resumen corto para retrieval rapido.
overview: L1 resumen medio para contexto.
content: L2 contenido completo.
name: nombre legible de la instancia.
tags: etiquetas para filtrado y busqueda.
created_at: timestamp de creacion.
updated_at: timestamp de ultima actualizacion.
"""
memory_type: str
uri: str | None = None
fields: dict[str, Any] = field(default_factory=dict)
abstract: str | None = None
overview: str | None = None
content: str | None = None
name: str | None = None
tags: list[str] = field(default_factory=list)
created_at: datetime | None = None
updated_at: datetime | None = None
def get_field(self, field_name: str) -> Any:
"""Retorna el valor de un campo dinamico por nombre.
Args:
field_name: nombre del campo a obtener.
Returns:
El valor del campo, o None si no existe.
"""
return self.fields.get(field_name)
def set_field(self, field_name: str, value: Any) -> None:
"""Establece el valor de un campo dinamico.
Args:
field_name: nombre del campo a establecer.
value: valor a asignar.
"""
self.fields[field_name] = value
+35
View File
@@ -0,0 +1,35 @@
---
name: memory_field
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class MemoryField:
name: str
field_type: FieldType
description: str = ""
merge_op: MergeOp = MergeOp.PATCH
description: "Definicion de un campo dentro de un tipo de memoria. El merge_op determina como se actualizan valores existentes: PATCH para cambios parciales, SUM para acumulacion numerica, IMMUTABLE para proteger el valor original."
tags: [memory, field, schema, dataclass]
uses_types: [field_type_py_core, merge_op_py_core]
file_path: "python/types/core/memory_field.py"
---
## Notas
Dataclass inmutable por convencion (no frozen para permitir construccion incremental).
El campo `description` es consumido por el LLM para entender que dato debe colocar en cada campo.
Usado por `MemoryTypeSchema` como elemento de la lista `fields`.
## Ejemplo
```python
MemoryField(
name="username",
field_type=FieldType.STRING,
description="Nombre de usuario del sistema",
merge_op=MergeOp.IMMUTABLE,
)
```
+23
View File
@@ -0,0 +1,23 @@
"""MemoryField — definicion de un campo dentro de un tipo de memoria."""
from dataclasses import dataclass, field
from field_type import FieldType
from merge_op import MergeOp
@dataclass
class MemoryField:
"""Definicion de un campo dentro de un tipo de memoria.
Attributes:
name: nombre del campo.
field_type: tipo de dato del campo.
description: descripcion para el LLM sobre el proposito del campo.
merge_op: estrategia de merge cuando se actualiza un valor existente.
"""
name: str
field_type: FieldType
description: str = ""
merge_op: MergeOp = MergeOp.PATCH
+57
View File
@@ -0,0 +1,57 @@
---
name: memory_type_schema
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class MemoryTypeSchema:
memory_type: str
description: str = ""
fields: list[MemoryField] = field(default_factory=list)
filename_template: str = ""
directory: str = ""
enabled: bool = True
operation_mode: str = "upsert"
description: "Schema de un tipo de memoria de agente. Define la estructura de los datos, la estrategia de merge por campo y como se organizan en el filesystem. Soporta modos upsert, add_only y update_only."
tags: [memory, schema, agent, dataclass, filesystem]
uses_types: [memory_field_py_core]
file_path: "python/types/core/memory_type_schema.py"
---
## Notas
`operation_mode` controla la semantica de escritura:
- `upsert`: crear si no existe, actualizar si existe (comportamiento default).
- `add_only`: solo crea nuevas entradas; si ya existe una con la misma URI, no la toca.
- `update_only`: solo actualiza entradas existentes; si no existe, descarta la operacion.
`filename_template` puede contener placeholders como `{name}` o `{date}` para generar nombres de archivo dinamicamente.
Categorias de memoria predefinidas recomendadas:
- `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
## Ejemplo
```python
MemoryTypeSchema(
memory_type="profile",
description="Informacion persistente del usuario",
fields=[
MemoryField("username", FieldType.STRING, "Nombre de usuario", MergeOp.IMMUTABLE),
MemoryField("role", FieldType.STRING, "Rol en el equipo", MergeOp.PATCH),
MemoryField("interaction_count", FieldType.INTEGER, "Total de interacciones", MergeOp.SUM),
],
filename_template="{username}.json",
directory="memory/profile",
operation_mode="upsert",
)
```
+34
View File
@@ -0,0 +1,34 @@
"""MemoryTypeSchema — schema de un tipo de memoria de agente."""
from dataclasses import dataclass, field
from memory_field import MemoryField
@dataclass
class MemoryTypeSchema:
"""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.
Attributes:
memory_type: nombre del tipo (ej: "profile", "preferences").
description: descripcion del tipo para el LLM.
fields: lista de campos que define la estructura.
filename_template: template para el nombre de archivo al persistir.
directory: directorio donde se almacenan las instancias.
enabled: si el tipo esta activo.
operation_mode: modo de operacion ("upsert" | "add_only" | "update_only").
"""
memory_type: str
description: str = ""
fields: list[MemoryField] = field(default_factory=list)
filename_template: str = ""
directory: str = ""
enabled: bool = True
operation_mode: str = "upsert"
+24
View File
@@ -0,0 +1,24 @@
---
name: merge_op
lang: py
domain: core
version: "1.0.0"
algebraic: sum
definition: |
class MergeOp(str, Enum):
PATCH = "patch"
SUM = "sum"
IMMUTABLE = "immutable"
description: "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."
tags: [memory, merge, strategy, enum, schema]
uses_types: []
file_path: "python/types/core/merge_op.py"
---
## Notas
Hereda de `str` para serializacion JSON directa.
Usado por `MemoryField` como valor default `MergeOp.PATCH`.
- `PATCH`: para strings aplica search/replace; para otros tipos reemplaza directamente.
- `SUM`: acumula numeros; util para contadores de eventos o frecuencias.
- `IMMUTABLE`: protege campos que no deben cambiar una vez establecidos (ej: fecha de creacion, ID).
+18
View File
@@ -0,0 +1,18 @@
"""MergeOp — estrategia de merge para campos de memoria."""
from enum import Enum
class MergeOp(str, Enum):
"""Estrategia de merge cuando se actualiza un campo de memoria existente.
Valores posibles:
PATCH — aplica cambios parciales (search/replace en strings,
replace directo para otros tipos).
SUM — acumula valores numericos (contadores).
IMMUTABLE — protege el campo contra modificacion despues de creado.
"""
PATCH = "patch"
SUM = "sum"
IMMUTABLE = "immutable"
+41
View File
@@ -0,0 +1,41 @@
---
name: message
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class Message:
id: str
role: str
parts: list[Part]
created_at: datetime | None = None
description: "Modelo de mensaje multipart para conversaciones con agentes. Soporta texto, referencias a contexto (memoria/recurso/skill) y llamadas a herramientas en un mensaje unificado. Disenado para ser serializable a JSONL para persistencia."
tags: [message, conversation, multipart, llm, agent, history, jsonl, serialization, tokens]
uses_types: [part_py_core, text_part_py_core, context_part_py_core, tool_part_py_core]
file_path: "python/types/core/message.py"
---
## Metodos
- `content` (property) — texto del primer `TextPart`. Vacio si no hay TextPart.
- `estimated_tokens` (property) — estimacion `ceil(total_chars / 4)`. Cuenta chars de TextParts, abstracts de ContextParts, y JSON de tool_input + tool_output de ToolParts.
- `to_dict() -> dict` — serializa a dict listo para `json.dumps()`. Timestamps en ISO 8601.
- `to_jsonl() -> str` — serializa a string JSON de una sola linea.
- `from_dict(data: dict) -> Message` (classmethod) — deserializa usando `part_from_dict()` para cada parte.
- `create_user(content: str) -> Message` (classmethod) — factory: role="user", un TextPart, UUID y timestamp UTC automaticos.
- `create_assistant(content, context_refs=None, tool_calls=None) -> Message` (classmethod) — factory: role="assistant", orden de partes: ContextParts → TextPart → ToolParts.
- `get_context_parts() -> list[ContextPart]` — filtra partes de tipo ContextPart.
- `get_tool_parts() -> list[ToolPart]` — filtra partes de tipo ToolPart.
- `find_tool_part(tool_id: str) -> ToolPart | None` — busca un ToolPart por tool_id.
## Notas
El campo `role` acepta "user" o "assistant" por convencion, pero no se valida — el tipo es abierto para compatibilidad con futuros roles (e.g., "system", "tool").
La estimacion de tokens (ceil(chars / 4)) es una heuristica. No sustituye a un tokenizador real, pero es util para presupuesto rapido sin dependencias externas.
`to_dict()` usa `dataclasses.asdict()` sobre cada Part, que serializa recursivamente incluyendo el campo `type` discriminador — esto garantiza que `from_dict()` puede reconstruir el tipo correcto.
Las factories `create_user` y `create_assistant` generan IDs con `uuid.uuid4()` y timestamps UTC automaticamente, simplificando la construccion en pipelines de conversacion.
+195
View File
@@ -0,0 +1,195 @@
"""Message — modelo de mensaje multipart para conversaciones con agentes."""
import dataclasses
import json
import math
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import ClassVar
from context_part import ContextPart
from part import Part, part_from_dict
from text_part import TextPart
from tool_part import ToolPart
@dataclass
class Message:
"""Modelo de mensaje multipart para conversaciones con agentes.
Soporta texto, referencias a contexto (memoria/recurso/skill) y llamadas
a herramientas en un mensaje unificado. Disenado para ser serializable a
JSONL para persistencia.
Attributes:
id: Identificador unico del mensaje.
role: Rol del emisor: "user" | "assistant".
parts: Lista de partes del mensaje (TextPart, ContextPart, ToolPart).
created_at: Timestamp de creacion en UTC.
"""
id: str
role: str
parts: list[Part]
created_at: datetime | None = None
# --- Properties ---
@property
def content(self) -> str:
"""Texto del primer TextPart del mensaje. Vacio si no hay TextPart."""
for part in self.parts:
if isinstance(part, TextPart):
return part.text
return ""
@property
def estimated_tokens(self) -> int:
"""Estimacion de tokens consumidos por el mensaje.
Cuenta caracteres de TextParts, abstracts de ContextParts, e inputs/outputs
de ToolParts. Divide entre 4 (heuristica estandar: ~4 chars por token).
Returns:
Numero entero de tokens estimados (ceil).
"""
total_chars = 0
for part in self.parts:
if isinstance(part, TextPart):
total_chars += len(part.text)
elif isinstance(part, ContextPart):
total_chars += len(part.abstract)
elif isinstance(part, ToolPart):
if part.tool_input:
total_chars += len(json.dumps(part.tool_input))
total_chars += len(part.tool_output)
return math.ceil(total_chars / 4)
# --- Serialization ---
def to_dict(self) -> dict:
"""Serializa el mensaje a un dict compatible con JSONL.
Returns:
Dict con todos los campos del mensaje listos para json.dumps().
"""
return {
"id": self.id,
"role": self.role,
"parts": [dataclasses.asdict(p) for p in self.parts],
"created_at": self.created_at.isoformat() if self.created_at else None,
}
def to_jsonl(self) -> str:
"""Serializa el mensaje a un string JSON en una sola linea (JSONL).
Returns:
String JSON sin saltos de linea internos.
"""
return json.dumps(self.to_dict(), ensure_ascii=False)
@classmethod
def from_dict(cls, data: dict) -> "Message":
"""Deserializa un Message desde un dict.
Args:
data: Dict con los campos del mensaje. Los dicts de 'parts'
se deserializan con part_from_dict().
Returns:
Instancia de Message reconstruida.
"""
parts = [part_from_dict(p) for p in data.get("parts", [])]
created_at = None
if data.get("created_at"):
created_at = datetime.fromisoformat(data["created_at"])
return cls(
id=data["id"],
role=data["role"],
parts=parts,
created_at=created_at,
)
# --- Factories ---
@classmethod
def create_user(cls, content: str) -> "Message":
"""Crea un mensaje de usuario con un unico TextPart.
Args:
content: Texto del mensaje.
Returns:
Message con role="user" y un TextPart.
"""
return cls(
id=str(uuid.uuid4()),
role="user",
parts=[TextPart(text=content)],
created_at=datetime.now(timezone.utc),
)
@classmethod
def create_assistant(
cls,
content: str,
context_refs: list[ContextPart] | None = None,
tool_calls: list[ToolPart] | None = None,
) -> "Message":
"""Crea un mensaje de asistente con texto, contextos opcionales y tool calls opcionales.
El orden de las partes es: ContextParts, TextPart, ToolParts.
Args:
content: Texto principal de la respuesta del asistente.
context_refs: Referencias a contextos que el asistente uso para responder.
tool_calls: Llamadas a herramientas realizadas por el asistente.
Returns:
Message con role="assistant" y las partes compuestas.
"""
parts: list[Part] = []
if context_refs:
parts.extend(context_refs)
parts.append(TextPart(text=content))
if tool_calls:
parts.extend(tool_calls)
return cls(
id=str(uuid.uuid4()),
role="assistant",
parts=parts,
created_at=datetime.now(timezone.utc),
)
# --- Accessors ---
def get_context_parts(self) -> list[ContextPart]:
"""Devuelve todas las partes de tipo ContextPart del mensaje.
Returns:
Lista (posiblemente vacia) de ContextParts.
"""
return [p for p in self.parts if isinstance(p, ContextPart)]
def get_tool_parts(self) -> list[ToolPart]:
"""Devuelve todas las partes de tipo ToolPart del mensaje.
Returns:
Lista (posiblemente vacia) de ToolParts.
"""
return [p for p in self.parts if isinstance(p, ToolPart)]
def find_tool_part(self, tool_id: str) -> ToolPart | None:
"""Busca un ToolPart por su tool_id.
Args:
tool_id: Identificador de la invocacion de la herramienta.
Returns:
El ToolPart encontrado, o None si no existe.
"""
for part in self.parts:
if isinstance(part, ToolPart) and part.tool_id == tool_id:
return part
return None
+36
View File
@@ -0,0 +1,36 @@
---
name: node_type
lang: py
domain: core
version: "1.0.0"
algebraic: sum
definition: |
class NodeType(str, Enum):
ROOT = "root"
SECTION = "section"
description: "Tipos de nodo en un arbol de documento parseado. Solo ROOT y SECTION — todo el contenido detallado permanece como markdown dentro del campo content del ResourceNode."
tags: [parsing, document, tree, enum, node, core]
uses_types: []
file_path: "python/types/core/node_type.py"
---
## Valores
- `ROOT` — nodo raiz del arbol, unico por documento.
- `SECTION` — seccion o subseccion del documento (cualquier nivel).
## Ejemplo
```python
from node_type import NodeType
print(NodeType.ROOT.value) # "root"
print(NodeType.SECTION.value) # "section"
node_type = NodeType("section")
print(node_type == NodeType.SECTION) # True
```
## Notas
Diseno intencionalmente simple. Evita la decomposicion fina de nodos (no hay PARAGRAPH, CODE_BLOCK, TABLE, LIST, etc.) para mantener el arbol manejable y el contenido preservado en formato markdown. Hereda de `str` para serializar directamente a JSON sin conversion manual.
+19
View File
@@ -0,0 +1,19 @@
"""NodeType — tipos de nodo en un arbol de documento parseado."""
from enum import Enum
class NodeType(str, Enum):
"""Tipos de nodo en un arbol de documento parseado.
Valores posibles:
ROOT — nodo raiz del arbol, unico por documento.
SECTION — seccion o subseccion del documento.
Diseno intencionalmente simple: no hay PARAGRAPH, CODE_BLOCK, TABLE, etc.
Todo el contenido detallado permanece como markdown dentro del campo
content del ResourceNode.
"""
ROOT = "root"
SECTION = "section"
+70
View File
@@ -0,0 +1,70 @@
---
name: parse_result
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class ParseResult:
root: ResourceNode
source_path: str | None = None
source_format: str | None = None
parser_name: str | None = None
parser_version: str | None = None
parse_time: float | None = None
parse_timestamp: datetime | None = None
meta: dict[str, Any] = field(default_factory=dict)
warnings: list[str] = field(default_factory=list)
description: "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 del registry."
tags: [parsing, document, result, tree, pipeline, core]
uses_types: [resource_node_py_core, node_type_py_core]
file_path: "python/types/core/parse_result.py"
---
## Propiedades y metodos
- `success -> bool` (property) — True si `warnings` esta vacio.
- `get_all_nodes() -> list[ResourceNode]` — flatten BFS del arbol completo (raiz + todos los descendientes).
- `get_sections(min_level=0, max_level=10) -> list[ResourceNode]` — filtra nodos de tipo SECTION dentro del rango de niveles.
## Helper
```python
def create_parse_result(
root, source_path=None, source_format=None,
parser_name=None, parse_time=None, meta=None, warnings=None
) -> ParseResult
```
Construye un ParseResult con valores por defecto sensatos. Evita tener que instanciar `ParseResult` directamente cuando no se necesitan todos los campos.
## Ejemplo
```python
import time
from node_type import NodeType
from resource_node import ResourceNode
from parse_result import ParseResult, create_parse_result
root = ResourceNode(type=NodeType.ROOT, title="Documento")
intro = ResourceNode(type=NodeType.SECTION, title="Intro", level=1, content="...")
root.add_child(intro)
result = create_parse_result(
root=root,
source_path="/docs/mi_doc.md",
source_format="markdown",
parser_name="MarkdownParser",
parse_time=0.042,
)
print(result.success) # True (sin warnings)
print(len(result.get_all_nodes())) # 2 (root + intro)
print(len(result.get_sections())) # 1 (solo intro, root es ROOT)
print(result.get_sections(min_level=1, max_level=1)) # [intro]
```
## Notas
El tipo de retorno unificado permite que los consumidores (pipelines, agentes, APIs) trabajen con el mismo contrato independientemente del formato de origen. El campo `warnings` captura problemas no fatales (e.g., encoding issues, headings malformados) sin lanzar excepciones. `get_all_nodes()` usa BFS para preservar el orden natural de lectura del documento.
+114
View File
@@ -0,0 +1,114 @@
"""ParseResult — resultado completo de parsear un documento."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from node_type import NodeType
from resource_node import ResourceNode
@dataclass
class ParseResult:
"""Resultado completo de parsear un documento.
Contiene el arbol de nodos, metadata del proceso de parsing y
lista de warnings. Es el tipo de retorno unificado de todos los
parsers del registry.
Attributes:
root: Nodo raiz del arbol de documento.
source_path: Path o URL del documento origen.
source_format: Formato del documento ("pdf", "markdown", "html", "docx", etc.).
parser_name: Nombre del parser usado ("PDFParser", "MarkdownParser", etc.).
parser_version: Version del parser.
parse_time: Duracion del parsing en segundos.
parse_timestamp: Momento en que se realizo el parsing.
meta: Metadata adicional del proceso.
warnings: Lista de advertencias no fatales durante el parsing.
"""
root: ResourceNode
source_path: str | None = None
source_format: str | None = None
parser_name: str | None = None
parser_version: str | None = None
parse_time: float | None = None
parse_timestamp: datetime | None = None
meta: dict[str, Any] = field(default_factory=dict)
warnings: list[str] = field(default_factory=list)
@property
def success(self) -> bool:
"""True si el parsing no genero warnings."""
return len(self.warnings) == 0
def get_all_nodes(self) -> list[ResourceNode]:
"""Retorna todos los nodos del arbol en orden de visita (BFS).
Returns:
Lista plana de ResourceNode incluyendo el nodo raiz y todos
sus descendientes.
"""
result: list[ResourceNode] = []
queue = [self.root]
while queue:
node = queue.pop(0)
result.append(node)
queue.extend(node.children)
return result
def get_sections(
self, min_level: int = 0, max_level: int = 10
) -> list[ResourceNode]:
"""Retorna todos los nodos SECTION dentro del rango de niveles indicado.
Args:
min_level: Nivel minimo de seccion (inclusive).
max_level: Nivel maximo de seccion (inclusive).
Returns:
Lista de ResourceNode de tipo SECTION dentro del rango.
"""
return [
node
for node in self.get_all_nodes()
if node.type == NodeType.SECTION
and min_level <= node.level <= max_level
]
def create_parse_result(
root: ResourceNode,
source_path: str | None = None,
source_format: str | None = None,
parser_name: str | None = None,
parse_time: float | None = None,
meta: dict[str, Any] | None = None,
warnings: list[str] | None = None,
) -> ParseResult:
"""Helper para construir un ParseResult con valores por defecto sensatos.
Args:
root: Nodo raiz del arbol de documento.
source_path: Path o URL del documento origen.
source_format: Formato del documento.
parser_name: Nombre del parser.
parse_time: Duracion del parsing en segundos.
meta: Metadata adicional.
warnings: Lista de warnings.
Returns:
ParseResult construido con los parametros dados.
"""
return ParseResult(
root=root,
source_path=source_path,
source_format=source_format,
parser_name=parser_name,
parse_time=parse_time,
meta=meta or {},
warnings=warnings or [],
)
+24
View File
@@ -0,0 +1,24 @@
---
name: part
lang: py
domain: core
version: "1.0.0"
algebraic: sum
definition: |
Part = Union[TextPart, ContextPart, ToolPart]
def part_from_dict(data: dict) -> Part:
"""Deserializa un Part desde dict, usando el campo 'type' como discriminador."""
description: "Tipo suma de las partes posibles de un mensaje multipart: texto, referencia a contexto o llamada a herramienta. Incluye part_from_dict() para deserializacion discriminada por el campo 'type'."
tags: [message, part, union, sum_type, multipart, llm, discriminator, deserialization]
uses_types: [text_part_py_core, context_part_py_core, tool_part_py_core]
file_path: "python/types/core/part.py"
---
## Notas
`Part` es un alias de tipo (`Union`) sobre los tres product types concretos. No es una clase — no se instancia directamente.
`part_from_dict()` usa el campo `"type"` como discriminador: `"text"``TextPart`, `"context"``ContextPart`, `"tool"``ToolPart`. Lanza `ValueError` si el tipo es desconocido o falta el campo.
Los campos del dict se pasan directamente al constructor del tipo concreto (excepto `"type"`, que ya es el valor por defecto). Esto permite round-trip: `part_from_dict(dataclasses.asdict(part))` devuelve una copia equivalente.
+37
View File
@@ -0,0 +1,37 @@
"""Part — tipo suma de las partes posibles de un mensaje multipart."""
from typing import Union
from context_part import ContextPart
from text_part import TextPart
from tool_part import ToolPart
Part = Union[TextPart, ContextPart, ToolPart]
_PART_TYPES: dict[str, type] = {
"text": TextPart,
"context": ContextPart,
"tool": ToolPart,
}
def part_from_dict(data: dict) -> Part:
"""Deserializa un Part desde un dict usando el campo 'type' como discriminador.
Args:
data: Dict con al menos el campo 'type' y los campos del Part correspondiente.
Returns:
La instancia del Part concreto (TextPart, ContextPart o ToolPart).
Raises:
ValueError: Si el campo 'type' no esta presente o es desconocido.
"""
kind = data.get("type")
if kind is None:
raise ValueError("El dict no contiene el campo 'type' requerido para deserializar Part")
cls = _PART_TYPES.get(kind)
if cls is None:
raise ValueError(f"Tipo de Part desconocido: '{kind}'. Valores validos: {list(_PART_TYPES)}")
fields = {k: v for k, v in data.items() if k != "type"}
return cls(**fields)
+25
View File
@@ -0,0 +1,25 @@
---
name: query_plan
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class QueryPlan:
queries: list[TypedQuery]
session_context: str
reasoning: str
description: "Plan de busqueda generado por analisis de intenciones (LLM). Contiene multiples queries tipadas y el razonamiento detras de la descomposicion."
tags: [retrieval, plan, rag, intent, llm, search]
uses_types: [typed_query_py_core]
file_path: "python/types/core/query_plan.py"
---
## Notas
`session_context` resume el estado de la conversacion o sesion que el LLM analizo para generar el plan. Permite al retriever entender el contexto sin acceder al historial completo.
`reasoning` es la cadena de pensamiento del LLM — util para debugging y observabilidad. Documenta por que se decidio descomponer la busqueda en esas queries especificas con esos tipos de contexto.
Las queries en la lista se ordenan convencionalmente por `priority` (1=alta) para que el retriever pueda ejecutar primero las mas importantes y aplicar early stopping si ya tiene suficiente contexto.
+26
View File
@@ -0,0 +1,26 @@
"""QueryPlan — plan de busqueda generado por analisis de intenciones (LLM)."""
from __future__ import annotations
from dataclasses import dataclass
from typed_query import TypedQuery
@dataclass
class QueryPlan:
"""Plan de busqueda generado por analisis de intenciones.
Contiene multiples queries tipadas y el razonamiento detras de la
descomposicion. Generado tipicamente por un LLM que analiza el contexto
de sesion y las intenciones del usuario.
Attributes:
queries: Lista de TypedQueries a ejecutar, ordenadas por prioridad.
session_context: Resumen del contexto de sesion que guio la descomposicion.
reasoning: Razonamiento del LLM sobre como descomponer la busqueda.
"""
queries: list[TypedQuery]
session_context: str
reasoning: str
+25
View File
@@ -0,0 +1,25 @@
---
name: query_result
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class QueryResult:
query: TypedQuery
matched_contexts: list[MatchedContext]
searched_directories: list[str]
description: "Resultado de una sola TypedQuery con los contextos encontrados y los directorios que se buscaron."
tags: [retrieval, result, rag, query, search]
uses_types: [typed_query_py_core, matched_context_py_core]
file_path: "python/types/core/query_result.py"
---
## Notas
Representa la respuesta a una unica TypedQuery dentro de un QueryPlan. El campo `searched_directories` permite auditar que directorios fueron efectivamente explorados, lo que puede diferir de los `target_directories` de la query si algunos no estaban disponibles.
`matched_contexts` se ordena convencionalmente por score descendente, pero el tipo no lo impone — es responsabilidad del retriever.
Un `FindResult` agrega multiples `QueryResult` para dar la vision consolidada por tipo de contexto.
+23
View File
@@ -0,0 +1,23 @@
"""QueryResult — resultado de una sola TypedQuery con contextos encontrados."""
from __future__ import annotations
from dataclasses import dataclass, field
from matched_context import MatchedContext
from typed_query import TypedQuery
@dataclass
class QueryResult:
"""Resultado de una sola TypedQuery con los contextos encontrados.
Attributes:
query: La TypedQuery que genero estos resultados.
matched_contexts: Lista de contextos encontrados, ordenados por score desc.
searched_directories: URIs de los directorios que se exploraron durante la busqueda.
"""
query: TypedQuery
matched_contexts: list[MatchedContext] = field(default_factory=list)
searched_directories: list[str] = field(default_factory=list)
+20
View File
@@ -0,0 +1,20 @@
---
name: related_context
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class RelatedContext:
uri: str
abstract: str
description: "Referencia minima a un contexto relacionado. Contiene el URI del recurso y un abstract de su contenido. Se usa como elemento de la lista `relations` dentro de MatchedContext."
tags: [retrieval, context, relation, rag, reference]
uses_types: []
file_path: "python/types/core/related_context.py"
---
## Notas
Tipo minimalista intencionalmente. No replica el score ni el nivel de detalle del contexto padre — solo provee suficiente informacion para que el consumidor pueda decidir si explorar el recurso relacionado.
+16
View File
@@ -0,0 +1,16 @@
"""RelatedContext — referencia a un contexto relacionado por URI y abstract."""
from dataclasses import dataclass
@dataclass
class RelatedContext:
"""Referencia minima a un contexto relacionado.
Attributes:
uri: Identificador unico del recurso relacionado.
abstract: Resumen breve del contenido del recurso.
"""
uri: str
abstract: str
@@ -0,0 +1,24 @@
---
name: resource_content_type
lang: py
domain: core
version: "1.0.0"
algebraic: sum
definition: |
class ResourceContentType(str, Enum):
TEXT = "text"
IMAGE = "image"
VIDEO = "video"
AUDIO = "audio"
BINARY = "binary"
description: "Tipo de contenido de un recurso indexable. Sum type con cinco variantes: text, image, video, audio, binary. Hereda de str para serializar directamente como string JSON."
tags: [content-type, resource, enum, media, indexing]
uses_types: []
file_path: "python/types/core/resource_content_type.py"
---
## Notas
Hereda de `str` ademas de `Enum` para que las variantes serialicen directamente como strings (`"text"`, `"image"`, etc.) sin necesidad de `.value`. Util al almacenar en bases de datos vectoriales o JSON sin conversion extra.
Usado por `Context` para describir el tipo de contenido del recurso que representa.
@@ -0,0 +1,21 @@
"""ResourceContentType — tipo de contenido de un recurso indexable."""
from enum import Enum
class ResourceContentType(str, Enum):
"""Tipo de contenido de un recurso.
Variantes:
TEXT: Contenido de texto plano o estructurado (markdown, code, etc.).
IMAGE: Imagen (PNG, JPEG, WebP, etc.).
VIDEO: Video (MP4, WebM, etc.).
AUDIO: Audio (MP3, WAV, etc.).
BINARY: Contenido binario arbitrario (PDF, ZIP, etc.).
"""
TEXT = "text"
IMAGE = "image"
VIDEO = "video"
AUDIO = "audio"
BINARY = "binary"
+65
View File
@@ -0,0 +1,65 @@
---
name: resource_node
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class ResourceNode:
type: NodeType
title: str | None = None
level: int = 0
content: str = ""
children: list["ResourceNode"] = field(default_factory=list)
meta: dict[str, Any] = field(default_factory=dict)
description: "Nodo en un arbol jerarquico de documento. Preserva la estructura natural del documento (secciones, subsecciones) sin perder contenido. Resultado intermedio de parsing — los parsers producen arboles de ResourceNode."
tags: [parsing, document, tree, node, hierarchy, markdown, core]
uses_types: [node_type_py_core]
file_path: "python/types/core/resource_node.py"
---
## Metodos
- `add_child(child: ResourceNode) -> None` — agrega un nodo hijo a la lista de children.
- `get_text(include_children: bool = True) -> str` — contenido concatenado del nodo y sus descendientes de forma recursiva.
- `get_abstract(max_length: int = 256) -> str` — resumen corto: el titulo o los primeros N caracteres del contenido.
- `get_overview(max_length: int = 4000) -> str` — overview con el contenido del nodo y lista de titulos de las secciones hijas.
- `to_dict() -> dict` — serializacion recursiva a diccionario (JSON-compatible).
- `from_dict(data: dict) -> ResourceNode` — deserializacion desde diccionario (classmethod).
## Ejemplo
```python
from node_type import NodeType
from resource_node import ResourceNode
root = ResourceNode(type=NodeType.ROOT, title="Mi Documento", level=0)
section = ResourceNode(
type=NodeType.SECTION,
title="Introduccion",
level=1,
content="Este documento describe...",
)
root.add_child(section)
subsection = ResourceNode(
type=NodeType.SECTION,
title="Contexto",
level=2,
content="El contexto es el siguiente...",
)
section.add_child(subsection)
print(root.get_abstract()) # "Mi Documento"
print(section.get_text()) # "Este documento describe...\n\nEl contexto es el siguiente..."
print(section.get_text(include_children=False)) # "Este documento describe..."
data = root.to_dict()
restored = ResourceNode.from_dict(data)
```
## Notas
El campo `content` almacena el markdown del nodo directamente — no se parsea internamente. Cada nodo representa una seccion del documento delimitada por su heading y el siguiente heading del mismo nivel. Los parsers construyen el arbol llamando `add_child` en orden de aparicion en el documento. El metodo `from_dict` es el inverso exacto de `to_dict` y permite round-trip sin perdida de informacion.
+135
View File
@@ -0,0 +1,135 @@
"""ResourceNode — nodo en un arbol jerarquico de documento."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from node_type import NodeType
@dataclass
class ResourceNode:
"""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 arbitraria.
Attributes:
type: Tipo del nodo (ROOT o SECTION).
title: Titulo de la seccion, None para el nodo raiz sin titulo.
level: Nivel de profundidad (0=root, 1=top section, 2=subsection, etc.).
content: Contenido markdown de este nodo (sin incluir el de hijos).
children: Lista de nodos hijos (subsecciones).
meta: Metadata arbitraria del nodo.
"""
type: NodeType
title: str | None = None
level: int = 0
content: str = ""
children: list[ResourceNode] = field(default_factory=list)
meta: dict[str, Any] = field(default_factory=dict)
def add_child(self, child: ResourceNode) -> None:
"""Agrega un nodo hijo a la lista de children.
Args:
child: Nodo hijo a agregar.
"""
self.children.append(child)
def get_text(self, include_children: bool = True) -> str:
"""Retorna el contenido concatenado del nodo y opcionalmente sus hijos.
Args:
include_children: Si True, incluye el contenido de todos los
descendientes de forma recursiva.
Returns:
String con el contenido completo.
"""
parts = [self.content] if self.content else []
if include_children:
for child in self.children:
child_text = child.get_text(include_children=True)
if child_text:
parts.append(child_text)
return "\n\n".join(parts)
def get_abstract(self, max_length: int = 256) -> str:
"""Retorna un resumen corto: el titulo o los primeros N caracteres del contenido.
Args:
max_length: Longitud maxima del resumen en caracteres.
Returns:
String con el titulo o inicio del contenido, truncado si es necesario.
"""
if self.title:
return self.title[:max_length]
text = self.content.strip()
if len(text) <= max_length:
return text
return text[:max_length].rstrip() + "..."
def get_overview(self, max_length: int = 4000) -> str:
"""Retorna un overview con el contenido del nodo y lista de secciones hijas.
Args:
max_length: Longitud maxima del overview en caracteres.
Returns:
String con el contenido del nodo seguido de los titulos de sus hijos.
"""
parts = []
if self.title:
parts.append(f"# {self.title}")
if self.content:
parts.append(self.content)
if self.children:
child_titles = [
f"- {child.title}" if child.title else f"- (section level {child.level})"
for child in self.children
]
parts.append("## Sections\n" + "\n".join(child_titles))
overview = "\n\n".join(parts)
if len(overview) <= max_length:
return overview
return overview[:max_length].rstrip() + "..."
def to_dict(self) -> dict:
"""Serializa el nodo y sus descendientes a un diccionario.
Returns:
dict con todos los campos del nodo, con children serializado
recursivamente.
"""
return {
"type": self.type.value,
"title": self.title,
"level": self.level,
"content": self.content,
"children": [child.to_dict() for child in self.children],
"meta": self.meta,
}
@classmethod
def from_dict(cls, data: dict) -> ResourceNode:
"""Deserializa un dict (producido por to_dict) a un ResourceNode.
Args:
data: Diccionario con los campos del nodo.
Returns:
ResourceNode reconstruido con todos sus descendientes.
"""
return cls(
type=NodeType(data["type"]),
title=data.get("title"),
level=data.get("level", 0),
content=data.get("content", ""),
children=[cls.from_dict(c) for c in data.get("children", [])],
meta=data.get("meta", {}),
)
+38
View File
@@ -0,0 +1,38 @@
---
name: round_summary
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@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
description: "Resumen de una ronda de simulacion multi-agente con tiempos, acciones y conteos."
tags: [simulation, round, summary, multi-agent, timeline]
uses_types: [agent_action_py_core]
file_path: "python/types/core/round_summary.py"
---
## Ejemplo
```python
summary = RoundSummary(
round_num=1,
start_time="2024-01-01T12:00:00Z",
end_time="2024-01-01T12:01:00Z",
simulated_hour=8.0,
actions=[action1, action2],
active_agents=5,
)
print(summary.to_dict())
```
## Notas
Tipo producto — todos los campos obligatorios estan siempre presentes. `end_time` es `None` mientras la ronda este en progreso. `actions` contiene instancias de `AgentAction` y se serializa llamando `to_dict()` en cada elemento. Util para replay de simulaciones ronda por ronda y analisis post-simulacion.
+43
View File
@@ -0,0 +1,43 @@
"""Resumen de una ronda de simulacion multi-agente."""
from __future__ import annotations
from dataclasses import dataclass, field
from agent_action import AgentAction
@dataclass
class RoundSummary:
"""Resumen de una ronda de simulacion multi-agente.
Agrupa las acciones de todos los agentes en esa ronda con tiempos y conteos.
Permite replay de simulaciones ronda por ronda, visualizacion de timeline,
y analisis post-simulacion.
Attributes:
round_num: Numero de ronda de la simulacion.
start_time: Timestamp ISO 8601 de inicio de la ronda.
end_time: Timestamp ISO 8601 de fin de la ronda (None si aun corre).
simulated_hour: Hora simulada dentro de la ronda (0.0 por defecto).
actions: Lista de acciones realizadas por los agentes en esta ronda.
active_agents: Numero de agentes activos durante la ronda.
"""
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
def to_dict(self) -> dict:
"""Convierte el resumen de ronda a un diccionario serializable."""
return {
"round_num": self.round_num,
"start_time": self.start_time,
"end_time": self.end_time,
"simulated_hour": self.simulated_hour,
"actions": [a.to_dict() for a in self.actions],
"active_agents": self.active_agents,
}
+36
View File
@@ -0,0 +1,36 @@
---
name: runner_status
lang: py
domain: core
version: "1.0.0"
algebraic: sum
definition: |
class RunnerStatus(str, Enum):
IDLE = "idle"
STARTING = "starting"
RUNNING = "running"
PAUSED = "paused"
STOPPING = "stopping"
STOPPED = "stopped"
COMPLETED = "completed"
FAILED = "failed"
description: "Maquina de estados para un runner de simulacion o pipeline de larga duracion."
tags: [simulation, runner, status, state-machine, enum]
uses_types: []
file_path: "python/types/core/runner_status.py"
---
## Transiciones validas
```
IDLE -> STARTING -> RUNNING
RUNNING -> PAUSED -> RUNNING (resume)
RUNNING -> STOPPING -> STOPPED
RUNNING -> COMPLETED
RUNNING -> FAILED
STARTING -> FAILED
```
## Notas
Tipo suma — exactamente uno de los estados es activo en cada momento. Hereda de `str` para que sea serializable a JSON directamente.
+25
View File
@@ -0,0 +1,25 @@
"""Estado de un runner de simulacion o pipeline de larga duracion."""
from enum import Enum
class RunnerStatus(str, Enum):
"""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
"""
IDLE = "idle"
STARTING = "starting"
RUNNING = "running"
PAUSED = "paused"
STOPPING = "stopping"
STOPPED = "stopped"
COMPLETED = "completed"
FAILED = "failed"
+33
View File
@@ -0,0 +1,33 @@
---
name: task
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class Task:
task_id: str
task_type: str
status: TaskStatus
created_at: datetime
updated_at: datetime
progress: int = 0
message: str = ""
result: dict | None = None
error: str | None = None
metadata: dict = field(default_factory=dict)
progress_detail: dict = field(default_factory=dict)
description: "Tarea de larga duracion con seguimiento de progreso. Product type que modela el ciclo de vida completo: creacion, progreso incremental, completado exitoso o fallo. Incluye to_dict() para serializacion JSON con timestamps ISO 8601."
tags: [task, async, background, progress, tracking, polling]
uses_types: [task_status_py_core]
file_path: "python/types/core/task.py"
---
## Notas
El campo `progress` es un entero 0-100. El campo `progress_detail` permite informar detalles granulares (etapa actual, items procesados) sin romper la interfaz del tipo.
El metodo `to_dict()` serializa `status` como string via `.value` y los timestamps como strings ISO 8601, haciendo el resultado directamente serializable con `json.dumps()`.
El campo `result` almacena el output de la tarea al completar. El campo `error` almacena la descripcion del fallo. Solo uno de los dos deberia estar poblado en un estado terminal.
+57
View File
@@ -0,0 +1,57 @@
"""Task — tarea de larga duracion con seguimiento de progreso."""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from task_status import TaskStatus
@dataclass
class Task:
"""Tarea de larga duracion con seguimiento de progreso.
Attributes:
task_id: UUID que identifica la tarea de forma unica.
task_type: Tipo libre: "graph_build", "simulation", "report_generation", etc.
status: Estado actual del ciclo de vida (TaskStatus).
created_at: Timestamp de creacion (UTC).
updated_at: Timestamp de ultima actualizacion (UTC).
progress: Porcentaje de avance 0-100.
message: Mensaje de estado legible por el usuario.
result: Resultado serializado al completar, None si no ha completado.
error: Descripcion del error al fallar, None si no ha fallado.
metadata: Datos arbitrarios asociados a la tarea (parametros de entrada, etc.).
progress_detail: Detalles granulares de progreso (etapa actual, items procesados, etc.).
"""
task_id: str
task_type: str
status: TaskStatus
created_at: datetime
updated_at: datetime
progress: int = 0
message: str = ""
result: dict | None = None
error: str | None = None
metadata: dict = field(default_factory=dict)
progress_detail: dict = field(default_factory=dict)
def to_dict(self) -> dict:
"""Serializa la tarea a dict con timestamps en formato ISO 8601.
Returns:
dict con todos los campos de la tarea listos para JSON.
"""
return {
"task_id": self.task_id,
"task_type": self.task_type,
"status": self.status.value,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"progress": self.progress,
"message": self.message,
"result": self.result,
"error": self.error,
"metadata": self.metadata,
"progress_detail": self.progress_detail,
}
+22
View File
@@ -0,0 +1,22 @@
---
name: task_status
lang: py
domain: core
version: "1.0.0"
algebraic: sum
definition: |
class TaskStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
description: "Estado de una tarea de larga duracion (graph building, simulation, report generation). Enum sum type con cuatro variantes: pending, processing, completed, failed."
tags: [task, status, enum, async, background]
uses_types: []
file_path: "python/types/core/task_status.py"
---
## Notas
Hereda de `str` para que los valores sean directamente serializables a JSON sin conversion adicional.
Usado por `Task` para expresar el ciclo de vida de una tarea background.
+19
View File
@@ -0,0 +1,19 @@
"""TaskStatus — estado de una tarea de larga duracion."""
from enum import Enum
class TaskStatus(str, Enum):
"""Estado de una tarea de larga duracion.
Valores posibles:
PENDING — creada, en cola.
PROCESSING — en ejecucion activa.
COMPLETED — terminada con exito.
FAILED — terminada con error.
"""
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
+22
View File
@@ -0,0 +1,22 @@
---
name: text_part
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class TextPart:
text: str = ""
type: str = "text"
description: "Parte de texto plano en un mensaje multipart. Contiene el contenido textual y el discriminador literal 'text' para deserializacion por tipo."
tags: [message, part, text, multipart, llm, conversation]
uses_types: []
file_path: "python/types/core/text_part.py"
---
## Notas
El campo `type` actua como discriminador literal en el tipo suma `Part`. Siempre vale `"text"`.
Es el tipo de parte mas basico: un contenedor simple para texto plano dentro de un `Message`.
+16
View File
@@ -0,0 +1,16 @@
"""TextPart — parte de texto de un mensaje multipart."""
from dataclasses import dataclass, field
@dataclass
class TextPart:
"""Parte de texto plano en un mensaje multipart.
Attributes:
text: Contenido textual de la parte.
type: Discriminador literal, siempre "text".
"""
text: str = ""
type: str = field(default="text", init=True)
+33
View File
@@ -0,0 +1,33 @@
---
name: tool_part
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class ToolPart:
tool_id: str = ""
tool_name: str = ""
tool_input: dict | None = None
tool_output: str = ""
tool_status: str = "pending"
duration_ms: float | None = None
prompt_tokens: int | None = None
completion_tokens: int | None = None
type: str = "tool"
description: "Registro de una llamada a herramienta (tool call) dentro de un mensaje multipart. Captura input, output, estado del ciclo de vida y metricas de uso de tokens para trazabilidad y presupuesto."
tags: [message, part, tool, tool_call, function_call, multipart, llm, tracing, tokens]
uses_types: []
file_path: "python/types/core/tool_part.py"
---
## Notas
`tool_status` modela el ciclo de vida de la llamada: "pending" (encolada), "running" (en ejecucion), "completed" (finalizada con exito), "error" (fallida).
`tool_input` es un dict arbitrario con los argumentos. `tool_output` es siempre string para mantener compatibilidad con la API de herramientas de LLMs (OpenAI, Anthropic).
`duration_ms`, `prompt_tokens` y `completion_tokens` son opcionales — se poblan cuando la herramienta instrumenta su ejecucion. Utiles para presupuesto de tokens y profiling.
El campo `type` actua como discriminador literal en el tipo suma `Part`. Siempre vale `"tool"`.
+30
View File
@@ -0,0 +1,30 @@
"""ToolPart — registro de una llamada a herramienta en un mensaje multipart."""
from dataclasses import dataclass, field
@dataclass
class ToolPart:
"""Registro de una llamada a herramienta (tool call) dentro de un mensaje multipart.
Attributes:
tool_id: Identificador unico de la invocacion de la herramienta.
tool_name: Nombre de la herramienta invocada.
tool_input: Argumentos de entrada pasados a la herramienta.
tool_output: Resultado devuelto por la herramienta como string.
tool_status: Estado de la llamada: "pending" | "running" | "completed" | "error".
duration_ms: Duracion de la llamada en milisegundos.
prompt_tokens: Tokens de entrada consumidos por la herramienta (si aplica).
completion_tokens: Tokens de salida generados por la herramienta (si aplica).
type: Discriminador literal, siempre "tool".
"""
tool_id: str = ""
tool_name: str = ""
tool_input: dict | None = None
tool_output: str = ""
tool_status: str = "pending"
duration_ms: float | None = None
prompt_tokens: int | None = None
completion_tokens: int | None = None
type: str = field(default="tool", init=True)
+27
View File
@@ -0,0 +1,27 @@
---
name: typed_query
lang: py
domain: core
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class TypedQuery:
query: str
context_type: ContextType | None
intent: str
priority: int = 3
target_directories: list[str] = field(default_factory=list)
description: "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."
tags: [retrieval, query, rag, intent, search, plan]
uses_types: [context_type_py_core]
file_path: "python/types/core/typed_query.py"
---
## Notas
`priority` sigue la convencion 1=alta, 5=baja (similar a niveles de issue). Valor por defecto 3 es prioridad media.
`context_type = None` indica que la query aplica a todos los tipos de contexto sin filtro. Util para queries generales que el retriever distribuye entre todas las categorias.
`target_directories` vacio significa buscar en todos los directorios disponibles. Con URIs especificos, el retriever limita la busqueda al subconjunto indicado — util para reducir ruido en colecciones grandes.
+30
View File
@@ -0,0 +1,30 @@
"""TypedQuery — query tipada resultante del analisis de intenciones."""
from __future__ import annotations
from dataclasses import dataclass, field
from context_type import ContextType
@dataclass
class TypedQuery:
"""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. Permite estrategias de
retrieval especificas por categoria.
Attributes:
query: Texto de la query de busqueda.
context_type: Tipo de contexto objetivo (MEMORY, RESOURCE, SKILL) o None para todos.
intent: Descripcion de la intencion detras de esta query.
priority: Prioridad 1-5 (1 es mayor prioridad).
target_directories: URIs de directorios donde buscar. Lista vacia = buscar en todos.
"""
query: str
context_type: ContextType | None
intent: str
priority: int = 3
target_directories: list[str] = field(default_factory=list)
@@ -0,0 +1,61 @@
---
name: deduplication_result
lang: py
domain: datascience
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class DeduplicationResult:
entities: list[EntityCandidate]
entity_id_map: dict[str, str]
name_to_id: dict[str, str]
merge_log: list[dict] = field(default_factory=list)
total_before: int = 0
total_after: int = 0
description: "Resultado de deduplicacion de entidades. name_to_id mapea todos los nombres originales (incluyendo aliases mergeados) a su ID final para resolver relaciones."
tags: [deduplication, entity, merge, knowledge-graph, nlp, datascience]
uses_types: [entity_candidate_py_datascience]
file_path: "python/types/datascience/deduplication_result.py"
---
## Campos
- `entities`: lista final de `EntityCandidate` despues de deduplicacion
- `entity_id_map`: mapa `nombre_normalizado → entity_id` para lookup rapido
- `name_to_id`: mapa de TODOS los nombres originales (incluyendo aliases) a su ID final
- `merge_log`: lista de eventos de merge, cada uno con `{"merged": [...], "into": "...", "score": 0.92}`
- `total_before`: numero de entidades antes de deduplicacion
- `total_after`: numero de entidades despues de deduplicacion
## Ejemplo
```python
from python.types.datascience.deduplication_result import DeduplicationResult
from python.types.datascience.entity_candidate import EntityCandidate
result = DeduplicationResult(
entities=[
EntityCandidate(
name="John Smith",
name_normalized="john smith",
merged_from=["John Smith", "Smith, John"],
),
],
entity_id_map={"john smith": "ent_001"},
name_to_id={
"John Smith": "ent_001",
"Smith, John": "ent_001",
"john smith": "ent_001",
},
merge_log=[
{"merged": ["John Smith", "Smith, John"], "into": "John Smith", "score": 0.92}
],
total_before=2,
total_after=1,
)
```
## Notas
El `name_to_id` es la clave para la resolucion de relaciones. Cuando una relacion dice `from_name="Smith, John"`, se puede buscar en `name_to_id` y encontrar el ID `ent_001` aunque el nombre canonico sea `"John Smith"`. `entity_id_map` es un subset que usa solo nombres normalizados, util para lookup durante la misma fase de deduplicacion.
@@ -0,0 +1,22 @@
"""DeduplicationResult — resultado del proceso de deduplicacion de entidades."""
from dataclasses import dataclass, field
from python.types.datascience.entity_candidate import EntityCandidate
@dataclass
class DeduplicationResult:
"""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.
"""
entities: list[EntityCandidate]
entity_id_map: dict[str, str]
name_to_id: dict[str, str]
merge_log: list[dict] = field(default_factory=list)
total_before: int = 0
total_after: int = 0
@@ -0,0 +1,56 @@
---
name: entity_candidate
lang: py
domain: datascience
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class EntityCandidate:
name: str
name_normalized: str = ""
type_ref: str = ""
type_label: str = ""
attributes: dict = field(default_factory=dict)
confidence: float = 0.0
source_chunk_indices: list[int] = field(default_factory=list)
merged_from: list[str] = field(default_factory=list)
description: "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."
tags: [extraction, entity, llm, knowledge-graph, nlp, datascience]
uses_types: []
file_path: "python/types/datascience/entity_candidate.py"
---
## Campos
- `name`: nombre crudo extraido del texto
- `name_normalized`: nombre normalizado para deduplicacion (se llena en la fase de dedup)
- `type_ref`: registry type ID del tipo de entidad (ej: `osint_person_go_cybersecurity`)
- `type_label`: label humano del tipo (ej: `"Person"`)
- `attributes`: metadata extraida del texto como diccionario libre
- `confidence`: score de confianza 0.0-1.0
- `source_chunk_indices`: indices de los chunks donde aparecio la entidad
- `merged_from`: nombres originales si el candidato fue creado por merge de varios
## Ejemplo
```python
from python.types.datascience.entity_candidate import EntityCandidate
candidate = EntityCandidate(
name="John Smith",
name_normalized="john smith",
type_ref="osint_person_go_cybersecurity",
type_label="Person",
attributes={"role": "CEO", "company": "Acme Corp"},
confidence=0.92,
source_chunk_indices=[0, 3],
merged_from=["John Smith", "Smith, John"],
)
print(candidate.to_dict())
```
## Notas
Tipo inmutable en su intencion pero declarado como dataclass mutable para permitir que las fases de deduplicacion rellenen `name_normalized` y que el pipeline complete los campos opcionales progresivamente. `merged_from` es la fuente de verdad para auditar que entidades se colapsaron juntas.
@@ -0,0 +1,34 @@
"""EntityCandidate — candidato de entidad extraido por el LLM."""
from dataclasses import dataclass, field
@dataclass
class EntityCandidate:
"""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.
"""
name: str
name_normalized: str = ""
type_ref: str = ""
type_label: str = ""
attributes: dict = field(default_factory=dict)
confidence: float = 0.0
source_chunk_indices: list[int] = field(default_factory=list)
merged_from: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
"""Serializa el candidato a un diccionario."""
return {
"name": self.name,
"name_normalized": self.name_normalized,
"type_ref": self.type_ref,
"type_label": self.type_label,
"attributes": self.attributes,
"confidence": self.confidence,
"source_chunk_indices": self.source_chunk_indices,
"merged_from": self.merged_from,
}
@@ -0,0 +1,52 @@
---
name: extraction_result
lang: py
domain: datascience
version: "1.0.0"
algebraic: product
definition: |
@dataclass
class ExtractionResult:
entities: list[EntityCandidate]
relations: list[RelationCandidate]
stats: ExtractionStats = field(default_factory=ExtractionStats)
description: "Resultado final del pipeline de extraccion. Contiene las listas deduplicadas de entidades y relaciones junto con las estadisticas del proceso completo."
tags: [extraction, result, pipeline, knowledge-graph, datascience]
uses_types: [entity_candidate_py_datascience, relation_candidate_py_datascience, extraction_stats_py_datascience]
file_path: "python/types/datascience/extraction_result.py"
---
## Campos
- `entities`: lista final de `EntityCandidate` deduplicados
- `relations`: lista final de `RelationCandidate` deduplicadas con IDs resueltos
- `stats`: `ExtractionStats` con metricas del proceso completo
## Ejemplo
```python
from python.types.datascience.entity_candidate import EntityCandidate
from python.types.datascience.extraction_result import ExtractionResult
from python.types.datascience.extraction_stats import ExtractionStats
from python.types.datascience.relation_candidate import RelationCandidate
result = ExtractionResult(
entities=[
EntityCandidate(name="John Smith", type_label="Person", confidence=0.9),
],
relations=[
RelationCandidate(
from_name="John Smith",
to_name="Acme Corp",
from_id="ent_001",
to_id="ent_002",
relation_type="works_at",
),
],
stats=ExtractionStats(total_chunks=10, final_entities_count=1, final_relations_count=1),
)
```
## Notas
Tipo de salida del pipeline completo de extraccion. Agrupa todo lo necesario para persistir el resultado en una base de datos de grafos o para pasarlo a la siguiente fase del pipeline (enriquecimiento, visualizacion, etc.).
@@ -0,0 +1,20 @@
"""ExtractionResult — resultado final del pipeline de extraccion."""
from dataclasses import dataclass, field
from python.types.datascience.entity_candidate import EntityCandidate
from python.types.datascience.extraction_stats import ExtractionStats
from python.types.datascience.relation_candidate import RelationCandidate
@dataclass
class ExtractionResult:
"""Resultado final del pipeline de extraccion de entidades y relaciones.
Contiene las listas deduplicadas de entidades y relaciones junto con
las estadisticas del proceso completo.
"""
entities: list[EntityCandidate]
relations: list[RelationCandidate]
stats: ExtractionStats = field(default_factory=ExtractionStats)

Some files were not shown because too many files have changed in this diff Show More