auto(0129): agents_dashboard — secret_store_cpp_infra + CMakeLists register #4
@@ -0,0 +1,6 @@
|
||||
from .setup_logger import setup_logger, get_logger
|
||||
|
||||
__all__ = [
|
||||
"setup_logger",
|
||||
"get_logger",
|
||||
]
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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}"
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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).
|
||||
@@ -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.")
|
||||
@@ -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.
|
||||
@@ -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.")
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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).
|
||||
@@ -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", {}),
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"`.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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", [])],
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
```
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
```
|
||||
@@ -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"
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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 [],
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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", {}),
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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`.
|
||||
@@ -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)
|
||||
@@ -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"`.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user