From 9fd0ca9caca18687aee9ab1e6fe96d9560dcf1db Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 5 Apr 2026 17:11:43 +0200 Subject: [PATCH] feat: funciones Python infra y tipos Python (core, datascience, infra) Infra: cache_to_file, cache_to_sqlite, http_download_file, http_get_json, http_post_json, read_file_with_encoding, safe_extract_zip, scan_directory, setup_logger, normalize_zip_filenames. Tipos: 30+ tipos core (agent_action, context, task, message, parse_result...), 6 tipos datascience (entity_candidate, extraction_result...), 2 tipos infra. Co-Authored-By: Claude Opus 4.6 (1M context) --- python/functions/infra/__init__.py | 6 + python/functions/infra/cache_to_file.md | 60 +++++ python/functions/infra/cache_to_file.py | 135 +++++++++++ python/functions/infra/cache_to_file_test.py | 54 +++++ python/functions/infra/cache_to_sqlite.md | 57 +++++ python/functions/infra/cache_to_sqlite.py | 142 ++++++++++++ .../functions/infra/cache_to_sqlite_test.py | 114 +++++++++ python/functions/infra/get_logger.md | 36 +++ python/functions/infra/http_download_file.md | 40 ++++ python/functions/infra/http_download_file.py | 60 +++++ .../infra/http_download_file_test.py | 84 +++++++ python/functions/infra/http_get_json.md | 41 ++++ python/functions/infra/http_get_json.py | 58 +++++ python/functions/infra/http_get_json_test.py | 87 +++++++ python/functions/infra/http_post_json.md | 40 ++++ python/functions/infra/http_post_json.py | 58 +++++ python/functions/infra/http_post_json_test.py | 76 ++++++ .../infra/normalize_zip_filenames.md | 49 ++++ .../infra/read_file_with_encoding.md | 45 ++++ .../infra/read_file_with_encoding.py | 45 ++++ .../infra/read_file_with_encoding_test.py | 81 +++++++ python/functions/infra/safe_extract_zip.md | 46 ++++ python/functions/infra/safe_extract_zip.py | 80 +++++++ .../functions/infra/safe_extract_zip_test.py | 206 +++++++++++++++++ python/functions/infra/scan_directory.md | 64 ++++++ python/functions/infra/scan_directory.py | 217 ++++++++++++++++++ python/functions/infra/scan_directory_test.py | 181 +++++++++++++++ python/functions/infra/setup_logger.md | 51 ++++ python/functions/infra/setup_logger.py | 85 +++++++ python/functions/infra/setup_logger_test.py | 49 ++++ python/types/core/agent_action.md | 43 ++++ python/types/core/agent_action.py | 48 ++++ python/types/core/base_parser.md | 33 +++ python/types/core/base_parser.py | 76 ++++++ python/types/core/code_entity.md | 84 +++++++ python/types/core/code_entity.py | 61 +++++ python/types/core/context.md | 39 ++++ python/types/core/context.py | 102 ++++++++ python/types/core/context_level.md | 22 ++ python/types/core/context_level.py | 20 ++ python/types/core/context_part.md | 28 +++ python/types/core/context_part.py | 20 ++ python/types/core/context_type.md | 22 ++ python/types/core/context_type.py | 19 ++ python/types/core/entity_node.md | 47 ++++ python/types/core/entity_node.py | 39 ++++ python/types/core/field_type.md | 25 ++ python/types/core/field_type.py | 25 ++ python/types/core/filtered_entities.md | 48 ++++ python/types/core/filtered_entities.py | 28 +++ python/types/core/find_result.md | 30 +++ python/types/core/find_result.py | 138 +++++++++++ python/types/core/matched_context.md | 33 +++ python/types/core/matched_context.py | 38 +++ python/types/core/memory_data.md | 57 +++++ python/types/core/memory_data.py | 62 +++++ python/types/core/memory_field.md | 35 +++ python/types/core/memory_field.py | 23 ++ python/types/core/memory_type_schema.md | 57 +++++ python/types/core/memory_type_schema.py | 34 +++ python/types/core/merge_op.md | 24 ++ python/types/core/merge_op.py | 18 ++ python/types/core/message.md | 41 ++++ python/types/core/message.py | 195 ++++++++++++++++ python/types/core/node_type.md | 36 +++ python/types/core/node_type.py | 19 ++ python/types/core/parse_result.md | 70 ++++++ python/types/core/parse_result.py | 114 +++++++++ python/types/core/part.md | 24 ++ python/types/core/part.py | 37 +++ python/types/core/query_plan.md | 25 ++ python/types/core/query_plan.py | 26 +++ python/types/core/query_result.md | 25 ++ python/types/core/query_result.py | 23 ++ python/types/core/related_context.md | 20 ++ python/types/core/related_context.py | 16 ++ python/types/core/resource_content_type.md | 24 ++ python/types/core/resource_content_type.py | 21 ++ python/types/core/resource_node.md | 65 ++++++ python/types/core/resource_node.py | 135 +++++++++++ python/types/core/round_summary.md | 38 +++ python/types/core/round_summary.py | 43 ++++ python/types/core/runner_status.md | 36 +++ python/types/core/runner_status.py | 25 ++ python/types/core/task.md | 33 +++ python/types/core/task.py | 57 +++++ python/types/core/task_status.md | 22 ++ python/types/core/task_status.py | 19 ++ python/types/core/text_part.md | 22 ++ python/types/core/text_part.py | 16 ++ python/types/core/tool_part.md | 33 +++ python/types/core/tool_part.py | 30 +++ python/types/core/typed_query.md | 27 +++ python/types/core/typed_query.py | 30 +++ .../types/datascience/deduplication_result.md | 61 +++++ .../types/datascience/deduplication_result.py | 22 ++ python/types/datascience/entity_candidate.md | 56 +++++ python/types/datascience/entity_candidate.py | 34 +++ python/types/datascience/extraction_result.md | 52 +++++ python/types/datascience/extraction_result.py | 20 ++ python/types/datascience/extraction_stats.md | 64 ++++++ python/types/datascience/extraction_stats.py | 25 ++ .../types/datascience/relation_candidate.md | 54 +++++ .../types/datascience/relation_candidate.py | 35 +++ .../types/datascience/score_distribution.md | 27 +++ .../types/datascience/score_distribution.py | 70 ++++++ python/types/infra/classified_file.md | 39 ++++ python/types/infra/classified_file.py | 16 ++ python/types/infra/directory_scan_result.md | 43 ++++ python/types/infra/directory_scan_result.py | 24 ++ 110 files changed, 5714 insertions(+) create mode 100644 python/functions/infra/__init__.py create mode 100644 python/functions/infra/cache_to_file.md create mode 100644 python/functions/infra/cache_to_file.py create mode 100644 python/functions/infra/cache_to_file_test.py create mode 100644 python/functions/infra/cache_to_sqlite.md create mode 100644 python/functions/infra/cache_to_sqlite.py create mode 100644 python/functions/infra/cache_to_sqlite_test.py create mode 100644 python/functions/infra/get_logger.md create mode 100644 python/functions/infra/http_download_file.md create mode 100644 python/functions/infra/http_download_file.py create mode 100644 python/functions/infra/http_download_file_test.py create mode 100644 python/functions/infra/http_get_json.md create mode 100644 python/functions/infra/http_get_json.py create mode 100644 python/functions/infra/http_get_json_test.py create mode 100644 python/functions/infra/http_post_json.md create mode 100644 python/functions/infra/http_post_json.py create mode 100644 python/functions/infra/http_post_json_test.py create mode 100644 python/functions/infra/normalize_zip_filenames.md create mode 100644 python/functions/infra/read_file_with_encoding.md create mode 100644 python/functions/infra/read_file_with_encoding.py create mode 100644 python/functions/infra/read_file_with_encoding_test.py create mode 100644 python/functions/infra/safe_extract_zip.md create mode 100644 python/functions/infra/safe_extract_zip.py create mode 100644 python/functions/infra/safe_extract_zip_test.py create mode 100644 python/functions/infra/scan_directory.md create mode 100644 python/functions/infra/scan_directory.py create mode 100644 python/functions/infra/scan_directory_test.py create mode 100644 python/functions/infra/setup_logger.md create mode 100644 python/functions/infra/setup_logger.py create mode 100644 python/functions/infra/setup_logger_test.py create mode 100644 python/types/core/agent_action.md create mode 100644 python/types/core/agent_action.py create mode 100644 python/types/core/base_parser.md create mode 100644 python/types/core/base_parser.py create mode 100644 python/types/core/code_entity.md create mode 100644 python/types/core/code_entity.py create mode 100644 python/types/core/context.md create mode 100644 python/types/core/context.py create mode 100644 python/types/core/context_level.md create mode 100644 python/types/core/context_level.py create mode 100644 python/types/core/context_part.md create mode 100644 python/types/core/context_part.py create mode 100644 python/types/core/context_type.md create mode 100644 python/types/core/context_type.py create mode 100644 python/types/core/entity_node.md create mode 100644 python/types/core/entity_node.py create mode 100644 python/types/core/field_type.md create mode 100644 python/types/core/field_type.py create mode 100644 python/types/core/filtered_entities.md create mode 100644 python/types/core/filtered_entities.py create mode 100644 python/types/core/find_result.md create mode 100644 python/types/core/find_result.py create mode 100644 python/types/core/matched_context.md create mode 100644 python/types/core/matched_context.py create mode 100644 python/types/core/memory_data.md create mode 100644 python/types/core/memory_data.py create mode 100644 python/types/core/memory_field.md create mode 100644 python/types/core/memory_field.py create mode 100644 python/types/core/memory_type_schema.md create mode 100644 python/types/core/memory_type_schema.py create mode 100644 python/types/core/merge_op.md create mode 100644 python/types/core/merge_op.py create mode 100644 python/types/core/message.md create mode 100644 python/types/core/message.py create mode 100644 python/types/core/node_type.md create mode 100644 python/types/core/node_type.py create mode 100644 python/types/core/parse_result.md create mode 100644 python/types/core/parse_result.py create mode 100644 python/types/core/part.md create mode 100644 python/types/core/part.py create mode 100644 python/types/core/query_plan.md create mode 100644 python/types/core/query_plan.py create mode 100644 python/types/core/query_result.md create mode 100644 python/types/core/query_result.py create mode 100644 python/types/core/related_context.md create mode 100644 python/types/core/related_context.py create mode 100644 python/types/core/resource_content_type.md create mode 100644 python/types/core/resource_content_type.py create mode 100644 python/types/core/resource_node.md create mode 100644 python/types/core/resource_node.py create mode 100644 python/types/core/round_summary.md create mode 100644 python/types/core/round_summary.py create mode 100644 python/types/core/runner_status.md create mode 100644 python/types/core/runner_status.py create mode 100644 python/types/core/task.md create mode 100644 python/types/core/task.py create mode 100644 python/types/core/task_status.md create mode 100644 python/types/core/task_status.py create mode 100644 python/types/core/text_part.md create mode 100644 python/types/core/text_part.py create mode 100644 python/types/core/tool_part.md create mode 100644 python/types/core/tool_part.py create mode 100644 python/types/core/typed_query.md create mode 100644 python/types/core/typed_query.py create mode 100644 python/types/datascience/deduplication_result.md create mode 100644 python/types/datascience/deduplication_result.py create mode 100644 python/types/datascience/entity_candidate.md create mode 100644 python/types/datascience/entity_candidate.py create mode 100644 python/types/datascience/extraction_result.md create mode 100644 python/types/datascience/extraction_result.py create mode 100644 python/types/datascience/extraction_stats.md create mode 100644 python/types/datascience/extraction_stats.py create mode 100644 python/types/datascience/relation_candidate.md create mode 100644 python/types/datascience/relation_candidate.py create mode 100644 python/types/datascience/score_distribution.md create mode 100644 python/types/datascience/score_distribution.py create mode 100644 python/types/infra/classified_file.md create mode 100644 python/types/infra/classified_file.py create mode 100644 python/types/infra/directory_scan_result.md create mode 100644 python/types/infra/directory_scan_result.py diff --git a/python/functions/infra/__init__.py b/python/functions/infra/__init__.py new file mode 100644 index 00000000..3968fa3f --- /dev/null +++ b/python/functions/infra/__init__.py @@ -0,0 +1,6 @@ +from .setup_logger import setup_logger, get_logger + +__all__ = [ + "setup_logger", + "get_logger", +] diff --git a/python/functions/infra/cache_to_file.md b/python/functions/infra/cache_to_file.md new file mode 100644 index 00000000..5b5f53af --- /dev/null +++ b/python/functions/infra/cache_to_file.md @@ -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`. diff --git a/python/functions/infra/cache_to_file.py b/python/functions/infra/cache_to_file.py new file mode 100644 index 00000000..adf4865f --- /dev/null +++ b/python/functions/infra/cache_to_file.py @@ -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) diff --git a/python/functions/infra/cache_to_file_test.py b/python/functions/infra/cache_to_file_test.py new file mode 100644 index 00000000..42afe8e8 --- /dev/null +++ b/python/functions/infra/cache_to_file_test.py @@ -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 #hash" + store.set(key, "safe") + assert store.get(key) == "safe" diff --git a/python/functions/infra/cache_to_sqlite.md b/python/functions/infra/cache_to_sqlite.md new file mode 100644 index 00000000..14bee2b8 --- /dev/null +++ b/python/functions/infra/cache_to_sqlite.md @@ -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. diff --git a/python/functions/infra/cache_to_sqlite.py b/python/functions/infra/cache_to_sqlite.py new file mode 100644 index 00000000..6173dbdb --- /dev/null +++ b/python/functions/infra/cache_to_sqlite.py @@ -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) diff --git a/python/functions/infra/cache_to_sqlite_test.py b/python/functions/infra/cache_to_sqlite_test.py new file mode 100644 index 00000000..fe017603 --- /dev/null +++ b/python/functions/infra/cache_to_sqlite_test.py @@ -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}" diff --git a/python/functions/infra/get_logger.md b/python/functions/infra/get_logger.md new file mode 100644 index 00000000..7bc6c41f --- /dev/null +++ b/python/functions/infra/get_logger.md @@ -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. diff --git a/python/functions/infra/http_download_file.md b/python/functions/infra/http_download_file.md new file mode 100644 index 00000000..a3bad352 --- /dev/null +++ b/python/functions/infra/http_download_file.md @@ -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). diff --git a/python/functions/infra/http_download_file.py b/python/functions/infra/http_download_file.py new file mode 100644 index 00000000..a219fee0 --- /dev/null +++ b/python/functions/infra/http_download_file.py @@ -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, + } diff --git a/python/functions/infra/http_download_file_test.py b/python/functions/infra/http_download_file_test.py new file mode 100644 index 00000000..e8ecf284 --- /dev/null +++ b/python/functions/infra/http_download_file_test.py @@ -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() diff --git a/python/functions/infra/http_get_json.md b/python/functions/infra/http_get_json.md new file mode 100644 index 00000000..216eff94 --- /dev/null +++ b/python/functions/infra/http_get_json.md @@ -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. diff --git a/python/functions/infra/http_get_json.py b/python/functions/infra/http_get_json.py new file mode 100644 index 00000000..ca214d61 --- /dev/null +++ b/python/functions/infra/http_get_json.py @@ -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 diff --git a/python/functions/infra/http_get_json_test.py b/python/functions/infra/http_get_json_test.py new file mode 100644 index 00000000..ddfbd089 --- /dev/null +++ b/python/functions/infra/http_get_json_test.py @@ -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() diff --git a/python/functions/infra/http_post_json.md b/python/functions/infra/http_post_json.md new file mode 100644 index 00000000..81267656 --- /dev/null +++ b/python/functions/infra/http_post_json.md @@ -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). diff --git a/python/functions/infra/http_post_json.py b/python/functions/infra/http_post_json.py new file mode 100644 index 00000000..5788285d --- /dev/null +++ b/python/functions/infra/http_post_json.py @@ -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 diff --git a/python/functions/infra/http_post_json_test.py b/python/functions/infra/http_post_json_test.py new file mode 100644 index 00000000..9cf22143 --- /dev/null +++ b/python/functions/infra/http_post_json_test.py @@ -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() diff --git a/python/functions/infra/normalize_zip_filenames.md b/python/functions/infra/normalize_zip_filenames.md new file mode 100644 index 00000000..7b308b38 --- /dev/null +++ b/python/functions/infra/normalize_zip_filenames.md @@ -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`. diff --git a/python/functions/infra/read_file_with_encoding.md b/python/functions/infra/read_file_with_encoding.md new file mode 100644 index 00000000..7cf2ec2e --- /dev/null +++ b/python/functions/infra/read_file_with_encoding.md @@ -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`. diff --git a/python/functions/infra/read_file_with_encoding.py b/python/functions/infra/read_file_with_encoding.py new file mode 100644 index 00000000..31d1ea02 --- /dev/null +++ b/python/functions/infra/read_file_with_encoding.py @@ -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}" + ) diff --git a/python/functions/infra/read_file_with_encoding_test.py b/python/functions/infra/read_file_with_encoding_test.py new file mode 100644 index 00000000..82f9ebd8 --- /dev/null +++ b/python/functions/infra/read_file_with_encoding_test.py @@ -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") diff --git a/python/functions/infra/safe_extract_zip.md b/python/functions/infra/safe_extract_zip.md new file mode 100644 index 00000000..44a1add2 --- /dev/null +++ b/python/functions/infra/safe_extract_zip.md @@ -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. diff --git a/python/functions/infra/safe_extract_zip.py b/python/functions/infra/safe_extract_zip.py new file mode 100644 index 00000000..dbae0847 --- /dev/null +++ b/python/functions/infra/safe_extract_zip.py @@ -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) diff --git a/python/functions/infra/safe_extract_zip_test.py b/python/functions/infra/safe_extract_zip_test.py new file mode 100644 index 00000000..1c23dfe8 --- /dev/null +++ b/python/functions/infra/safe_extract_zip_test.py @@ -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.") diff --git a/python/functions/infra/scan_directory.md b/python/functions/infra/scan_directory.md new file mode 100644 index 00000000..5f7d6e86 --- /dev/null +++ b/python/functions/infra/scan_directory.md @@ -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. diff --git a/python/functions/infra/scan_directory.py b/python/functions/infra/scan_directory.py new file mode 100644 index 00000000..7e6bc5be --- /dev/null +++ b/python/functions/infra/scan_directory.py @@ -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 diff --git a/python/functions/infra/scan_directory_test.py b/python/functions/infra/scan_directory_test.py new file mode 100644 index 00000000..690b3d7c --- /dev/null +++ b/python/functions/infra/scan_directory_test.py @@ -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.") diff --git a/python/functions/infra/setup_logger.md b/python/functions/infra/setup_logger.md new file mode 100644 index 00000000..3c91514d --- /dev/null +++ b/python/functions/infra/setup_logger.md @@ -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. diff --git a/python/functions/infra/setup_logger.py b/python/functions/infra/setup_logger.py new file mode 100644 index 00000000..58dccbc0 --- /dev/null +++ b/python/functions/infra/setup_logger.py @@ -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) diff --git a/python/functions/infra/setup_logger_test.py b/python/functions/infra/setup_logger_test.py new file mode 100644 index 00000000..df547cd2 --- /dev/null +++ b/python/functions/infra/setup_logger_test.py @@ -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() diff --git a/python/types/core/agent_action.md b/python/types/core/agent_action.md new file mode 100644 index 00000000..7aa0f7c9 --- /dev/null +++ b/python/types/core/agent_action.md @@ -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. diff --git a/python/types/core/agent_action.py b/python/types/core/agent_action.py new file mode 100644 index 00000000..d0b786de --- /dev/null +++ b/python/types/core/agent_action.py @@ -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, + } diff --git a/python/types/core/base_parser.md b/python/types/core/base_parser.md new file mode 100644 index 00000000..b46f9461 --- /dev/null +++ b/python/types/core/base_parser.md @@ -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. diff --git a/python/types/core/base_parser.py b/python/types/core/base_parser.py new file mode 100644 index 00000000..cb6fc851 --- /dev/null +++ b/python/types/core/base_parser.py @@ -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 diff --git a/python/types/core/code_entity.md b/python/types/core/code_entity.md new file mode 100644 index 00000000..ee878669 --- /dev/null +++ b/python/types/core/code_entity.md @@ -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). diff --git a/python/types/core/code_entity.py b/python/types/core/code_entity.py new file mode 100644 index 00000000..b4be94a7 --- /dev/null +++ b/python/types/core/code_entity.py @@ -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, + ) diff --git a/python/types/core/context.md b/python/types/core/context.md new file mode 100644 index 00000000..6de8a511 --- /dev/null +++ b/python/types/core/context.md @@ -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). diff --git a/python/types/core/context.py b/python/types/core/context.py new file mode 100644 index 00000000..e56f0cc4 --- /dev/null +++ b/python/types/core/context.py @@ -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", {}), + ) diff --git a/python/types/core/context_level.md b/python/types/core/context_level.md new file mode 100644 index 00000000..6c262ef7 --- /dev/null +++ b/python/types/core/context_level.md @@ -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. diff --git a/python/types/core/context_level.py b/python/types/core/context_level.py new file mode 100644 index 00000000..53978643 --- /dev/null +++ b/python/types/core/context_level.py @@ -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 diff --git a/python/types/core/context_part.md b/python/types/core/context_part.md new file mode 100644 index 00000000..a81510bd --- /dev/null +++ b/python/types/core/context_part.md @@ -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"`. diff --git a/python/types/core/context_part.py b/python/types/core/context_part.py new file mode 100644 index 00000000..075e50d2 --- /dev/null +++ b/python/types/core/context_part.py @@ -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) diff --git a/python/types/core/context_type.md b/python/types/core/context_type.md new file mode 100644 index 00000000..4c1dcfc9 --- /dev/null +++ b/python/types/core/context_type.md @@ -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. diff --git a/python/types/core/context_type.py b/python/types/core/context_type.py new file mode 100644 index 00000000..b96a1e71 --- /dev/null +++ b/python/types/core/context_type.py @@ -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" diff --git a/python/types/core/entity_node.md b/python/types/core/entity_node.md new file mode 100644 index 00000000..cc614825 --- /dev/null +++ b/python/types/core/entity_node.md @@ -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. diff --git a/python/types/core/entity_node.py b/python/types/core/entity_node.py new file mode 100644 index 00000000..e08719a8 --- /dev/null +++ b/python/types/core/entity_node.py @@ -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 diff --git a/python/types/core/field_type.md b/python/types/core/field_type.md new file mode 100644 index 00000000..2369241a --- /dev/null +++ b/python/types/core/field_type.md @@ -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. diff --git a/python/types/core/field_type.py b/python/types/core/field_type.py new file mode 100644 index 00000000..51a08bec --- /dev/null +++ b/python/types/core/field_type.py @@ -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" diff --git a/python/types/core/filtered_entities.md b/python/types/core/filtered_entities.md new file mode 100644 index 00000000..e43fe81c --- /dev/null +++ b/python/types/core/filtered_entities.md @@ -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. diff --git a/python/types/core/filtered_entities.py b/python/types/core/filtered_entities.py new file mode 100644 index 00000000..b9d34a2e --- /dev/null +++ b/python/types/core/filtered_entities.py @@ -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, + } diff --git a/python/types/core/find_result.md b/python/types/core/find_result.md new file mode 100644 index 00000000..d239faa3 --- /dev/null +++ b/python/types/core/find_result.md @@ -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. diff --git a/python/types/core/find_result.py b/python/types/core/find_result.py new file mode 100644 index 00000000..91dc443a --- /dev/null +++ b/python/types/core/find_result.py @@ -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", [])], + ) diff --git a/python/types/core/matched_context.md b/python/types/core/matched_context.md new file mode 100644 index 00000000..47c8a6dc --- /dev/null +++ b/python/types/core/matched_context.md @@ -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. diff --git a/python/types/core/matched_context.py b/python/types/core/matched_context.py new file mode 100644 index 00000000..a86c7bba --- /dev/null +++ b/python/types/core/matched_context.py @@ -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) diff --git a/python/types/core/memory_data.md b/python/types/core/memory_data.md new file mode 100644 index 00000000..27e1d346 --- /dev/null +++ b/python/types/core/memory_data.md @@ -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" +``` diff --git a/python/types/core/memory_data.py b/python/types/core/memory_data.py new file mode 100644 index 00000000..b60461c5 --- /dev/null +++ b/python/types/core/memory_data.py @@ -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 diff --git a/python/types/core/memory_field.md b/python/types/core/memory_field.md new file mode 100644 index 00000000..04b35239 --- /dev/null +++ b/python/types/core/memory_field.md @@ -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, +) +``` diff --git a/python/types/core/memory_field.py b/python/types/core/memory_field.py new file mode 100644 index 00000000..f8ae2f7c --- /dev/null +++ b/python/types/core/memory_field.py @@ -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 diff --git a/python/types/core/memory_type_schema.md b/python/types/core/memory_type_schema.md new file mode 100644 index 00000000..491f0f5c --- /dev/null +++ b/python/types/core/memory_type_schema.md @@ -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", +) +``` diff --git a/python/types/core/memory_type_schema.py b/python/types/core/memory_type_schema.py new file mode 100644 index 00000000..4582d6bc --- /dev/null +++ b/python/types/core/memory_type_schema.py @@ -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" diff --git a/python/types/core/merge_op.md b/python/types/core/merge_op.md new file mode 100644 index 00000000..94549293 --- /dev/null +++ b/python/types/core/merge_op.md @@ -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). diff --git a/python/types/core/merge_op.py b/python/types/core/merge_op.py new file mode 100644 index 00000000..6155fbbf --- /dev/null +++ b/python/types/core/merge_op.py @@ -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" diff --git a/python/types/core/message.md b/python/types/core/message.md new file mode 100644 index 00000000..601d157c --- /dev/null +++ b/python/types/core/message.md @@ -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. diff --git a/python/types/core/message.py b/python/types/core/message.py new file mode 100644 index 00000000..73404a14 --- /dev/null +++ b/python/types/core/message.py @@ -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 diff --git a/python/types/core/node_type.md b/python/types/core/node_type.md new file mode 100644 index 00000000..ad1ac8d6 --- /dev/null +++ b/python/types/core/node_type.md @@ -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. diff --git a/python/types/core/node_type.py b/python/types/core/node_type.py new file mode 100644 index 00000000..56a2f3b1 --- /dev/null +++ b/python/types/core/node_type.py @@ -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" diff --git a/python/types/core/parse_result.md b/python/types/core/parse_result.md new file mode 100644 index 00000000..23f70f06 --- /dev/null +++ b/python/types/core/parse_result.md @@ -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. diff --git a/python/types/core/parse_result.py b/python/types/core/parse_result.py new file mode 100644 index 00000000..d1afa87f --- /dev/null +++ b/python/types/core/parse_result.py @@ -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 [], + ) diff --git a/python/types/core/part.md b/python/types/core/part.md new file mode 100644 index 00000000..c3fce1a4 --- /dev/null +++ b/python/types/core/part.md @@ -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. diff --git a/python/types/core/part.py b/python/types/core/part.py new file mode 100644 index 00000000..8a37b7f8 --- /dev/null +++ b/python/types/core/part.py @@ -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) diff --git a/python/types/core/query_plan.md b/python/types/core/query_plan.md new file mode 100644 index 00000000..f83f5650 --- /dev/null +++ b/python/types/core/query_plan.md @@ -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. diff --git a/python/types/core/query_plan.py b/python/types/core/query_plan.py new file mode 100644 index 00000000..182c91bf --- /dev/null +++ b/python/types/core/query_plan.py @@ -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 diff --git a/python/types/core/query_result.md b/python/types/core/query_result.md new file mode 100644 index 00000000..c7ac3cce --- /dev/null +++ b/python/types/core/query_result.md @@ -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. diff --git a/python/types/core/query_result.py b/python/types/core/query_result.py new file mode 100644 index 00000000..060aba1e --- /dev/null +++ b/python/types/core/query_result.py @@ -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) diff --git a/python/types/core/related_context.md b/python/types/core/related_context.md new file mode 100644 index 00000000..8c8248a9 --- /dev/null +++ b/python/types/core/related_context.md @@ -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. diff --git a/python/types/core/related_context.py b/python/types/core/related_context.py new file mode 100644 index 00000000..3afbdd4e --- /dev/null +++ b/python/types/core/related_context.py @@ -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 diff --git a/python/types/core/resource_content_type.md b/python/types/core/resource_content_type.md new file mode 100644 index 00000000..78e4038e --- /dev/null +++ b/python/types/core/resource_content_type.md @@ -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. diff --git a/python/types/core/resource_content_type.py b/python/types/core/resource_content_type.py new file mode 100644 index 00000000..822f9706 --- /dev/null +++ b/python/types/core/resource_content_type.py @@ -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" diff --git a/python/types/core/resource_node.md b/python/types/core/resource_node.md new file mode 100644 index 00000000..45f63ec4 --- /dev/null +++ b/python/types/core/resource_node.md @@ -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. diff --git a/python/types/core/resource_node.py b/python/types/core/resource_node.py new file mode 100644 index 00000000..0fce5a49 --- /dev/null +++ b/python/types/core/resource_node.py @@ -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", {}), + ) diff --git a/python/types/core/round_summary.md b/python/types/core/round_summary.md new file mode 100644 index 00000000..02625d9b --- /dev/null +++ b/python/types/core/round_summary.md @@ -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. diff --git a/python/types/core/round_summary.py b/python/types/core/round_summary.py new file mode 100644 index 00000000..c33e782d --- /dev/null +++ b/python/types/core/round_summary.py @@ -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, + } diff --git a/python/types/core/runner_status.md b/python/types/core/runner_status.md new file mode 100644 index 00000000..0be46d97 --- /dev/null +++ b/python/types/core/runner_status.md @@ -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. diff --git a/python/types/core/runner_status.py b/python/types/core/runner_status.py new file mode 100644 index 00000000..c4f8c136 --- /dev/null +++ b/python/types/core/runner_status.py @@ -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" diff --git a/python/types/core/task.md b/python/types/core/task.md new file mode 100644 index 00000000..d0441867 --- /dev/null +++ b/python/types/core/task.md @@ -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. diff --git a/python/types/core/task.py b/python/types/core/task.py new file mode 100644 index 00000000..74169b3d --- /dev/null +++ b/python/types/core/task.py @@ -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, + } diff --git a/python/types/core/task_status.md b/python/types/core/task_status.md new file mode 100644 index 00000000..c5837342 --- /dev/null +++ b/python/types/core/task_status.md @@ -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. diff --git a/python/types/core/task_status.py b/python/types/core/task_status.py new file mode 100644 index 00000000..428e596a --- /dev/null +++ b/python/types/core/task_status.py @@ -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" diff --git a/python/types/core/text_part.md b/python/types/core/text_part.md new file mode 100644 index 00000000..98da912d --- /dev/null +++ b/python/types/core/text_part.md @@ -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`. diff --git a/python/types/core/text_part.py b/python/types/core/text_part.py new file mode 100644 index 00000000..66e033e0 --- /dev/null +++ b/python/types/core/text_part.py @@ -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) diff --git a/python/types/core/tool_part.md b/python/types/core/tool_part.md new file mode 100644 index 00000000..2cd802f9 --- /dev/null +++ b/python/types/core/tool_part.md @@ -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"`. diff --git a/python/types/core/tool_part.py b/python/types/core/tool_part.py new file mode 100644 index 00000000..ad9c1c2a --- /dev/null +++ b/python/types/core/tool_part.py @@ -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) diff --git a/python/types/core/typed_query.md b/python/types/core/typed_query.md new file mode 100644 index 00000000..d031e246 --- /dev/null +++ b/python/types/core/typed_query.md @@ -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. diff --git a/python/types/core/typed_query.py b/python/types/core/typed_query.py new file mode 100644 index 00000000..aefecbbc --- /dev/null +++ b/python/types/core/typed_query.py @@ -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) diff --git a/python/types/datascience/deduplication_result.md b/python/types/datascience/deduplication_result.md new file mode 100644 index 00000000..aee87afc --- /dev/null +++ b/python/types/datascience/deduplication_result.md @@ -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. diff --git a/python/types/datascience/deduplication_result.py b/python/types/datascience/deduplication_result.py new file mode 100644 index 00000000..a4938a2a --- /dev/null +++ b/python/types/datascience/deduplication_result.py @@ -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 diff --git a/python/types/datascience/entity_candidate.md b/python/types/datascience/entity_candidate.md new file mode 100644 index 00000000..6a91f907 --- /dev/null +++ b/python/types/datascience/entity_candidate.md @@ -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. diff --git a/python/types/datascience/entity_candidate.py b/python/types/datascience/entity_candidate.py new file mode 100644 index 00000000..eaa9db4d --- /dev/null +++ b/python/types/datascience/entity_candidate.py @@ -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, + } diff --git a/python/types/datascience/extraction_result.md b/python/types/datascience/extraction_result.md new file mode 100644 index 00000000..1df73bce --- /dev/null +++ b/python/types/datascience/extraction_result.md @@ -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.). diff --git a/python/types/datascience/extraction_result.py b/python/types/datascience/extraction_result.py new file mode 100644 index 00000000..6f58224f --- /dev/null +++ b/python/types/datascience/extraction_result.py @@ -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) diff --git a/python/types/datascience/extraction_stats.md b/python/types/datascience/extraction_stats.md new file mode 100644 index 00000000..8855305e --- /dev/null +++ b/python/types/datascience/extraction_stats.md @@ -0,0 +1,64 @@ +--- +name: extraction_stats +lang: py +domain: datascience +version: "1.0.0" +algebraic: product +definition: | + @dataclass + class ExtractionStats: + total_chunks: int = 0 + total_chars: int = 0 + raw_entities_count: int = 0 + final_entities_count: int = 0 + entities_merged: int = 0 + raw_relations_count: int = 0 + final_relations_count: int = 0 + relations_merged: int = 0 + relations_discarded: int = 0 + entity_types_found: dict[str, int] = field(default_factory=dict) + relation_types_found: dict[str, int] = field(default_factory=dict) + processing_time_seconds: float = 0.0 +description: "Estadisticas del proceso de extraccion de entidades y relaciones. Registra conteos antes y despues de deduplicacion, tiempo de procesamiento y distribucion de tipos." +tags: [extraction, stats, reporting, debugging, datascience] +uses_types: [] +file_path: "python/types/datascience/extraction_stats.py" +--- + +## Campos + +- `total_chunks`: numero total de chunks procesados +- `total_chars`: caracteres totales procesados +- `raw_entities_count`: entidades extraidas antes de deduplicacion +- `final_entities_count`: entidades restantes despues de deduplicacion +- `entities_merged`: cuantas entidades se colapsaron en otras +- `raw_relations_count`: relaciones extraidas antes de deduplicacion +- `final_relations_count`: relaciones restantes despues de deduplicacion +- `relations_merged`: relaciones duplicadas que se colapsaron +- `relations_discarded`: self-loops o relaciones sin match de entidad descartadas +- `entity_types_found`: mapa `type_ref → count` de entidades por tipo +- `relation_types_found`: mapa `relation_type → count` de relaciones por tipo +- `processing_time_seconds`: tiempo total del pipeline en segundos + +## Ejemplo + +```python +from python.types.datascience.extraction_stats import ExtractionStats + +stats = ExtractionStats( + total_chunks=42, + total_chars=85000, + raw_entities_count=120, + final_entities_count=34, + entities_merged=86, + raw_relations_count=200, + final_relations_count=67, + relations_discarded=5, + entity_types_found={"Person": 18, "Organization": 16}, + processing_time_seconds=12.4, +) +``` + +## Notas + +Acumulable progresivamente durante el pipeline. Util para detectar documentos con baja densidad de informacion (pocos entities por chunk) o pipelines lentos. El ratio `entities_merged / raw_entities_count` indica que tan redundante fue el texto fuente. diff --git a/python/types/datascience/extraction_stats.py b/python/types/datascience/extraction_stats.py new file mode 100644 index 00000000..107ac5c6 --- /dev/null +++ b/python/types/datascience/extraction_stats.py @@ -0,0 +1,25 @@ +"""ExtractionStats — estadisticas del proceso de extraccion.""" + +from dataclasses import dataclass, field + + +@dataclass +class ExtractionStats: + """Estadisticas del proceso de extraccion. + + Util para reporting y debugging. Registra conteos antes y despues de + deduplicacion, tiempo de procesamiento y distribucion de tipos encontrados. + """ + + total_chunks: int = 0 + total_chars: int = 0 + raw_entities_count: int = 0 + final_entities_count: int = 0 + entities_merged: int = 0 + raw_relations_count: int = 0 + final_relations_count: int = 0 + relations_merged: int = 0 + relations_discarded: int = 0 + entity_types_found: dict[str, int] = field(default_factory=dict) + relation_types_found: dict[str, int] = field(default_factory=dict) + processing_time_seconds: float = 0.0 diff --git a/python/types/datascience/relation_candidate.md b/python/types/datascience/relation_candidate.md new file mode 100644 index 00000000..a488b3e8 --- /dev/null +++ b/python/types/datascience/relation_candidate.md @@ -0,0 +1,54 @@ +--- +name: relation_candidate +lang: py +domain: datascience +version: "1.0.0" +algebraic: product +definition: | + @dataclass + class RelationCandidate: + from_name: str + to_name: str + from_id: str = "" + to_id: str = "" + relation_type: str = "" + description: str = "" + confidence: float = 0.0 + source_chunk_index: int = -1 +description: "Candidato de relacion entre dos entidades extraido por el LLM. from_id y to_id se resuelven durante la deduplicacion." +tags: [extraction, relation, llm, knowledge-graph, nlp, datascience] +uses_types: [] +file_path: "python/types/datascience/relation_candidate.py" +--- + +## Campos + +- `from_name`: nombre crudo de la entidad origen tal como aparece en el texto +- `to_name`: nombre crudo de la entidad destino +- `from_id`: entity ID resuelto (se llena en la fase de deduplicacion) +- `to_id`: entity ID resuelto del destino +- `relation_type`: tipo de relacion segun los presets del dominio +- `description`: descripcion textual libre de la relacion +- `confidence`: score de confianza 0.0-1.0 +- `source_chunk_index`: indice del chunk del que se extrajo (-1 si es desconocido) + +## Ejemplo + +```python +from python.types.datascience.relation_candidate import RelationCandidate + +rel = RelationCandidate( + from_name="John Smith", + to_name="Acme Corp", + relation_type="works_at", + description="John Smith es CEO de Acme Corp", + confidence=0.88, + source_chunk_index=2, +) + +print(rel.to_dict()) +``` + +## Notas + +Los campos `from_id` y `to_id` empiezan vacios y se resuelven contra el `name_to_id` del `DeduplicationResult`. Relaciones donde no se puede resolver alguno de los IDs se descartan y se cuentan en `ExtractionStats.relations_discarded`. Los self-loops (`from_id == to_id`) tambien se descartan. diff --git a/python/types/datascience/relation_candidate.py b/python/types/datascience/relation_candidate.py new file mode 100644 index 00000000..524d546a --- /dev/null +++ b/python/types/datascience/relation_candidate.py @@ -0,0 +1,35 @@ +"""RelationCandidate — candidato de relacion extraido por el LLM.""" + +from dataclasses import dataclass + + +@dataclass +class RelationCandidate: + """Candidato de relacion entre dos entidades extraido por el LLM. + + `from_name` y `to_name` contienen los nombres crudos del texto. `from_id` + y `to_id` se llenan durante la fase de deduplicacion cuando se resuelven + contra los EntityCandidate finales. + """ + + from_name: str + to_name: str + from_id: str = "" + to_id: str = "" + relation_type: str = "" + description: str = "" + confidence: float = 0.0 + source_chunk_index: int = -1 + + def to_dict(self) -> dict: + """Serializa el candidato a un diccionario.""" + return { + "from_name": self.from_name, + "to_name": self.to_name, + "from_id": self.from_id, + "to_id": self.to_id, + "relation_type": self.relation_type, + "description": self.description, + "confidence": self.confidence, + "source_chunk_index": self.source_chunk_index, + } diff --git a/python/types/datascience/score_distribution.md b/python/types/datascience/score_distribution.md new file mode 100644 index 00000000..5d63d2aa --- /dev/null +++ b/python/types/datascience/score_distribution.md @@ -0,0 +1,27 @@ +--- +name: score_distribution +lang: py +domain: datascience +version: "1.0.0" +algebraic: product +definition: | + @dataclass + class ScoreDistribution: + scores: list[tuple[str, float]] + min_score: float = 0.0 + max_score: float = 0.0 + mean_score: float = 0.0 + threshold: float = 0.0 +description: "Estadisticas de distribucion de scores para visualizacion y debugging de retrieval. Incluye classmethod from_scores() que calcula stats automaticamente y to_dict() con count y above_threshold." +tags: [retrieval, score, distribution, statistics, debugging, rag] +uses_types: [] +file_path: "python/types/datascience/score_distribution.py" +--- + +## Notas + +`from_scores(uri_scores, threshold)` es el constructor principal. Ordena los scores de mayor a menor y calcula min, max y mean automaticamente. El parametro `threshold` solo se almacena en el campo del mismo nombre — el filtrado real lo aplica el retriever. + +`to_dict()` incluye `count` (total de items) y `above_threshold` (items con score >= threshold). Estos campos adicionales facilitan el logging y la observabilidad sin necesidad de recalcular en el consumidor. + +Los scores estan en rango 0.0-1.0 tipicamente, aunque el tipo no lo valida — la responsabilidad de normalizacion es del retriever. diff --git a/python/types/datascience/score_distribution.py b/python/types/datascience/score_distribution.py new file mode 100644 index 00000000..87b37a8f --- /dev/null +++ b/python/types/datascience/score_distribution.py @@ -0,0 +1,70 @@ +"""ScoreDistribution — estadisticas de distribucion de scores para retrieval.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class ScoreDistribution: + """Estadisticas de distribucion de scores para visualizacion y debugging de retrieval. + + Attributes: + scores: Lista de tuplas (uri, score) ordenadas de mayor a menor score. + min_score: Score minimo en la distribucion. + max_score: Score maximo en la distribucion. + mean_score: Score medio de la distribucion. + threshold: Umbral aplicado para filtrar resultados. + """ + + scores: list[tuple[str, float]] = field(default_factory=list) + min_score: float = 0.0 + max_score: float = 0.0 + mean_score: float = 0.0 + threshold: float = 0.0 + + @classmethod + def from_scores( + cls, + uri_scores: list[tuple[str, float]], + threshold: float = 0.0, + ) -> ScoreDistribution: + """Construye ScoreDistribution calculando estadisticas desde una lista de scores. + + Args: + uri_scores: Lista de tuplas (uri, score), no necesariamente ordenada. + threshold: Umbral de inclusion. Solo afecta el campo `threshold` del resultado. + + Returns: + ScoreDistribution con scores ordenados desc y estadisticas calculadas. + """ + if not uri_scores: + return cls(scores=[], threshold=threshold) + + sorted_scores = sorted(uri_scores, key=lambda x: x[1], reverse=True) + raw_scores = [s for _, s in sorted_scores] + + return cls( + scores=sorted_scores, + min_score=min(raw_scores), + max_score=max(raw_scores), + mean_score=sum(raw_scores) / len(raw_scores), + threshold=threshold, + ) + + def to_dict(self) -> dict: + """Serializa la distribucion a dict incluyendo count y above_threshold. + + Returns: + dict con scores, stats, count total y count de scores sobre el umbral. + """ + above = [s for _, s in self.scores if s >= self.threshold] + return { + "scores": [{"uri": uri, "score": score} for uri, score in self.scores], + "min_score": self.min_score, + "max_score": self.max_score, + "mean_score": self.mean_score, + "threshold": self.threshold, + "count": len(self.scores), + "above_threshold": len(above), + } diff --git a/python/types/infra/classified_file.md b/python/types/infra/classified_file.md new file mode 100644 index 00000000..f1dfa1c6 --- /dev/null +++ b/python/types/infra/classified_file.md @@ -0,0 +1,39 @@ +--- +name: classified_file +lang: py +domain: infra +version: "1.0.0" +algebraic: product +definition: | + @dataclass + class ClassifiedFile: + path: str + rel_path: str + classification: str +description: "Archivo clasificado durante un escaneo de directorio. Contiene el path absoluto, el path relativo a la raiz del escaneo (con forward slashes), y la clasificacion: 'processable' o 'unsupported'." +tags: [file, directory, scan, classification, infra] +uses_types: [] +file_path: "python/types/infra/classified_file.py" +--- + +## Campos + +- `path` — path absoluto del archivo en el sistema de archivos. +- `rel_path` — path relativo a la raiz del escaneo, siempre con forward slashes (`/`). +- `classification` — `"processable"` si la extension esta entre las soportadas, `"unsupported"` en caso contrario. + +## Ejemplo + +```python +from classified_file import ClassifiedFile + +f = ClassifiedFile( + path="/home/user/docs/report.pdf", + rel_path="docs/report.pdf", + classification="processable", +) +``` + +## Notas + +Tipo producto inmutable usado como elemento de `DirectoryScanResult.processable` y `DirectoryScanResult.unsupported`. El campo `rel_path` siempre usa forward slashes independientemente del OS para garantizar consistencia cross-platform. diff --git a/python/types/infra/classified_file.py b/python/types/infra/classified_file.py new file mode 100644 index 00000000..9d0d33f8 --- /dev/null +++ b/python/types/infra/classified_file.py @@ -0,0 +1,16 @@ +"""ClassifiedFile — archivo clasificado durante un escaneo de directorio.""" + +from dataclasses import dataclass + + +@dataclass +class ClassifiedFile: + """Archivo clasificado durante un escaneo de directorio. + + Contiene el path absoluto, el path relativo a la raiz del escaneo, + y la clasificacion del archivo segun su extension. + """ + + path: str + rel_path: str + classification: str # "processable" | "unsupported" diff --git a/python/types/infra/directory_scan_result.md b/python/types/infra/directory_scan_result.md new file mode 100644 index 00000000..0de34045 --- /dev/null +++ b/python/types/infra/directory_scan_result.md @@ -0,0 +1,43 @@ +--- +name: directory_scan_result +lang: py +domain: infra +version: "1.0.0" +algebraic: product +definition: | + @dataclass + class DirectoryScanResult: + root: str + processable: list[ClassifiedFile] = field(default_factory=list) + unsupported: list[ClassifiedFile] = field(default_factory=list) + skipped: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) +description: "Resultado de un escaneo de arbol de directorios. Agrupa archivos procesables, no soportados, paths saltados (con razon) y warnings generados durante el escaneo." +tags: [directory, scan, filesystem, result, infra] +uses_types: [classified_file_py_infra] +file_path: "python/types/infra/directory_scan_result.py" +--- + +## Campos + +- `root` — path absoluto del directorio raiz escaneado. +- `processable` — lista de `ClassifiedFile` con clasificacion `"processable"`. +- `unsupported` — lista de `ClassifiedFile` con clasificacion `"unsupported"`. +- `skipped` — lista de strings con formato `"path (reason)"` para archivos/dirs omitidos. +- `warnings` — mensajes de advertencia no fatales generados durante el escaneo. + +## Ejemplo + +```python +from scan_directory import scan_directory + +result = scan_directory("/data/docs", supported_extensions={".pdf", ".md"}) +print(f"Procesables: {len(result.processable)}") +print(f"No soportados: {len(result.unsupported)}") +for s in result.skipped: + print(f" Saltado: {s}") +``` + +## Notas + +Producido exclusivamente por `scan_directory`. Los campos `processable` y `unsupported` estan ordenados por `rel_path`. El campo `skipped` incluye tanto directorios podados como archivos individuales ignorados (dot files, symlinks, archivos vacios). diff --git a/python/types/infra/directory_scan_result.py b/python/types/infra/directory_scan_result.py new file mode 100644 index 00000000..36aefebf --- /dev/null +++ b/python/types/infra/directory_scan_result.py @@ -0,0 +1,24 @@ +"""DirectoryScanResult — resultado de un escaneo de arbol de directorios.""" + +from dataclasses import dataclass, field + +from classified_file import ClassifiedFile + + +@dataclass +class DirectoryScanResult: + """Resultado de un escaneo de arbol de directorios. + + Agrupa los archivos procesables, no soportados, los paths saltados + y los warnings generados durante el escaneo. + """ + + root: str + processable: list[ClassifiedFile] = field(default_factory=list) + unsupported: list[ClassifiedFile] = field(default_factory=list) + skipped: list[str] = field(default_factory=list) # "path (reason)" + warnings: list[str] = field(default_factory=list) + + def all_processable_files(self) -> list[ClassifiedFile]: + """Alias de processable para una API mas clara.""" + return self.processable