# functions

id: metabase_validate_sql_py_infra
name: metabase_validate_sql
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","validation","sql","pre-flight","impure","query","syntax-check"]
uses_functions: ["metabase_execute_query_py_infra","metabase_auth_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/validation.py
created_at: 2026-04-13T10:12:25Z
updated_at: 2026-04-13T13:02:40Z
props: []
emits: []
variant: []
content_hash: 7ce34fdded40ed130c6e1bc7fbbf9e398173062be48db1e49f157cb8c6dd800c
signature:
  def metabase_validate_sql(client: MetabaseClient, database_id: int, sql: str, max_rows: int = 0) -> dict

description:
  Valida sintaxis y referencias de una query SQL ejecutandola contra Metabase via POST /api/dataset. Captura tanto errores HTTP como errores embebidos en el body (Metabase a veces devuelve 200 con status failed). Retorna ok, error y rows_returned.

example:
  ```python
  result = metabase_validate_sql(client, database_id=1, sql="SELECT id FROM orders LIMIT 1")
  if not result["ok"]:
      print(f"SQL invalido: {result['error']}")
  else:
      print(f"SQL valido, {result['rows_returned']} filas retornadas")
  ```

notes:
  No tiene tests automatizados porque requiere una instancia Metabase corriendo con una base de datos real. Para testear manualmente:

  ```python
  client = metabase_auth("http://localhost:3000", "admin@example.com", "pass")
  result = metabase_validate_sql(client, 1, "SELECT * FROM tabla_inexistente")
  # result["ok"] == False, result["error"] == "Table 'tabla_inexistente' not found"
  ```

  Comportamiento ante errores:
  - `httpx.HTTPStatusError` (4xx/5xx): extrae el campo `error` o `message` del JSON del response de Metabase.
  - Body con `status: "failed"` (Metabase devuelve 200 en algunos errores de query): captura el campo `error` del body.
  - Cualquier otra excepcion: convierte a string y la incluye en el campo `error`.

  Usa `metabase_execute_query` internamente, que mapea a POST /api/dataset.

code:
[cell repeats an earlier one in this file: 20024 characters]

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient con sesion activa"},{"name":"database_id","desc":"ID de la base de datos en Metabase contra la que ejecutar el SQL"},{"name":"sql","desc":"sentencia SQL a validar (SELECT, WITH, etc.) — se ejecuta realmente contra la BD"},{"name":"max_rows","desc":"limite de filas a retornar durante la validacion para minimizar carga (0 = default de Metabase, tipicamente 2000)"}],"output":"dict con tres claves: ok (bool) indica si la query se ejecuto sin error; error (str|None) mensaje de error si ok=False; rows_returned (int) numero de filas devueltas si ok=True"}


id: prosemirror_card_embed_py_infra
name: prosemirror_card_embed
kind: function
lang: py
domain: infra
version: 1.0.0
purity: pure
signature: def prosemirror_card_embed(card_id: int, height: int = 400) -> dict
tags: ["metabase","document","prosemirror","cardEmbed","resizeNode","pure","python"]
uses_functions: []
uses_types: []
returns: []
imports: ["uuid"]
tests: []
file_path: python/functions/metabase/documents.py
created_at: 2026-04-14T14:46:24Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: b7da4b1ff10715c3e94aafd04aba90154a2492b258023120c69df9e1771da46f
description:
  Genera un nodo ProseMirror cardEmbed envuelto en resizeNode con altura adecuada. Un cardEmbed desnudo renderiza con ~50px — este helper produce el formato que Metabase espera para que la card se vea bien.

example:
  ```python
  from metabase.documents import metabase_create_document, prosemirror_card_embed

  doc = metabase_create_document(client, "Reporte", {
      "type": "doc",
      "content": [
          {"type": "heading", "attrs": {"level": 2},
           "content": [{"type": "text", "text": "Revenue"}]},
          prosemirror_card_embed(7711, height=500),
          {"type": "paragraph",
           "content": [{"type": "text", "text": "Datos actualizados."}]},
      ]
  })
  ```

documentation:
  ## Por qué existe este helper

  ```python
  # MAL — cardEmbed desnudo: renderiza con ~50px de alto, ilegible
  {"type": "cardEmbed", "attrs": {"id": 42}}

  # BIEN — prosemirror_card_embed: envuelto en resizeNode
  prosemirror_card_embed(42, height=450)
  # → {"type": "resizeNode", "attrs": {"height": 450, "minHeight": 280},
  #    "content": [{"type": "cardEmbed", "attrs": {"id": 42, "_id": "uuid...", "name": null}}]}
  ```

  El formato con `resizeNode` es el que usa el editor de Metabase cuando un usuario inserta una card manualmente — sin él, la card se renderiza diminuta.

code:
[cell repeats an earlier one in this file: 13730 characters]

params_schema:
  {"params":[{"name":"card_id","desc":"ID de la card/pregunta de Metabase a embeber"},{"name":"height","desc":"altura en pixeles del embed (default 400). minHeight se fija en 280"}],"output":"dict ProseMirror: nodo resizeNode \u003e cardEmbed, insertable en el array content de un document"}


id: jupyter_discover_py_notebook
name: jupyter_discover
kind: function
lang: py
domain: notebook
version: 1.1.0
purity: impure
signature: def jupyter_discover(registry_root: str = "", ports: list[int] | None = None) -> list[dict]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["json","os","urllib.error","urllib.request","pathlib"]
tests: []
file_path: python/functions/notebook/jupyter_discover.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: b3d41f06d086bb40d7d10cb1380a8009ce0b9b78f27f50fa00d03bf7b60e6fa8
description:
  Descubre instancias de Jupyter Lab activas escaneando archivos .jupyter-port en analysis/ y puertos comunes (8888-8892). Detecta el root_dir real de cada instancia via /proc/pid/cmdline (Linux) para identificar correctamente el analisis en escenarios multi-instancia. Para cada instancia consulta /api/status, /api/config, /api/kernels y /api/sessions via HTTP REST.

tags:
  ["jupyter","notebook","discovery","api","http","kernels","sessions","analysis","multi-instance","proc"]

example:
  ```python
  from notebook.jupyter_discover import jupyter_discover

  # Descubrir con deteccion automatica de puertos
  instances = jupyter_discover(registry_root="/home/lucas/fn_registry")

  # Escanear puertos especificos
  instances = jupyter_discover(ports=[8888, 8900])

  for inst in instances:
      print(inst["url"], inst["analysis"], inst["root_dir"], inst["collaborative"])
  # http://localhost:8888 estudio_mercados /home/lucas/fn_registry/analysis/estudio_mercados True
  ```

notes:
  Solo usa stdlib: `urllib`, `json`, `pathlib`, `os`. No requiere `requests` ni clientes Jupyter especializados.

  El escaneo de puertos tiene un timeout de 2 segundos por instancia para no bloquear en puertos cerrados.

  ### Deteccion de root_dir via /proc

  En Linux, `_find_jupyter_pid_for_port()` escanea `/proc/*/cmdline` buscando el proceso Jupyter que tenga `--port=N` o `--ServerApp.port=N` en sus argumentos. Para el puerto default 8888, acepta cualquier proceso jupyter sin `--port` explicito.

  Una vez encontrado el PID, `_get_root_dir_from_proc()` lee los argumentos buscando `--ServerApp.root_dir=` o `--notebook-dir=`. Si ninguno esta presente, usa el cwd del proceso (`/proc/{pid}/cwd`) como fallback.

  La funcion `_extract_analysis_name()` extrae el nombre del analisis del root_dir: si el path contiene `analysis/{nombre}`, retorna `{nombre}`; en caso contrario retorna el ultimo componente del path.

  Esta cadena es mas fiable que confiar solo en `.jupyter-port` porque detecta el directorio real del proceso, no el registrado al arranque.

  ### Prioridad de fuentes para analysis name

  1. root_dir detectado via /proc (mas fiable)
  2. Hint del archivo .jupyter-port (fallback si /proc no esta disponible)
  3. Cadena vacia si ninguno funciona

  ### Modo colaborativo

  La deteccion de modo colaborativo busca `YDocExtension` o `collaborative` en el JSON de `/api/config`. Esto cubre tanto jupyter-collaboration >= 2.x (que expone la extension bajo `LabApp`) como configuraciones antiguas.

  Archivos `.jupyter-port`: el pipeline `init_jupyter_analysis` escribe este archivo en cada analisis al lanzar Jupyter, permitiendo que `jupyter_discover` los encuentre sin escanear todos los puertos.

documentation:
  ## Estructura del dict retornado

  Cada elemento de la lista tiene la siguiente forma:

  ```python
  {
      "url": "http://localhost:8888",
      "port": 8888,
      "analysis": "estudio_mercados",        # nombre del subdirectorio en analysis/, detectado via /proc
      "root_dir": "/home/lucas/fn_registry/analysis/estudio_mercados",  # path absoluto real del proceso
      "collaborative": True,                 # True si YDocExtension esta activo
      "kernels": [
          {
              "id": "abc123...",
              "name": "python3",
              "execution_state": "idle",
              "last_activity": "2026-04-01T10:00:00.000Z"
          }
      ],
      "sessions": [
          {
              "notebook": "notebooks/01_exploracion.ipynb",
              "kernel_id": "abc123...",
              "kernel_state": "idle"
          }
      ]
  }
  ```

  ## CLI

  ```bash
  # Descubrir con deteccion automatica
  python python/functions/notebook/jupyter_discover.py --registry-root /home/lucas/fn_registry

  # Puertos especificos, salida JSON
  python python/functions/notebook/jupyter_discover.py --port 8888 --port 8889 --json

  # Usando variable de entorno
  FN_REGISTRY_ROOT=/home/lucas/fn_registry python python/functions/notebook/jupyter_discover.py
  ```

  Ejemplo de salida en modo texto con multi-instancia:

  ```
  Puerto 8888  [colaborativo]
    url:      http://localhost:8888
    analysis: estudio_mercados
    root_dir: /home/lucas/fn_registry/analysis/estudio_mercados
    kernels (1):
      - python3  estado=idle  id=abc12345...
    sesiones (1):
      - notebooks/01.ipynb  kernel=abc12345...  estado=idle

  Puerto 8889  [estandar]
    url:      http://localhost:8889
    analysis: estudio_embeddings
    root_dir: /home/lucas/fn_registry/analysis/estudio_embeddings
    kernels: ninguno
    sesiones: ninguna
  ```

code:
  """Descubrimiento de instancias Jupyter Lab activas via API REST."""

  import json
  import os
  import urllib.error
  import urllib.request
  from pathlib import Path


  _DEFAULT_PORTS = [8888, 8889, 8890, 8891, 8892]


  def _get(url: str, timeout: float = 2.0) -> dict | list | None:
      """Hace GET a url y retorna el JSON parseado, o None si falla."""
      try:
          with urllib.request.urlopen(url, timeout=timeout) as resp:
              return json.loads(resp.read().decode())
      except Exception:
          return None


  def _is_collaborative(config: dict | list | None) -> bool:
      """Detecta si el servidor tiene jupyter-collaboration/YDocExtension activo."""
      if not isinstance(config, dict):
          return False
      # Jupyter Lab expone la config de extensiones bajo claves como
      # 'LabApp' o similares; la presencia de 'collaborative' o 'YDocExtension'
      # en cualquier valor de primer nivel indica modo colaborativo.
      raw = json.dumps(config).lower()
      return "ydocextension" in raw or "collaborative" in raw


  def _find_jupyter_pid_for_port(port: int) -> int | None:
      """Busca en /proc el PID del proceso jupyter que escucha en el puerto dado.

      Solo funciona en Linux (donde /proc existe). Retorna None si no encuentra
      el proceso o si /proc no esta disponible.
      """
      proc_dir = Path("/proc")
      if not proc_dir.is_dir():
          return None

      for pid_entry in proc_dir.iterdir():
          if not pid_entry.name.isdigit():
              continue
          cmdline_path = pid_entry / "cmdline"
          try:
              raw = cmdline_path.read_bytes().decode("utf-8", errors="replace")
          except OSError:
              continue

          if "jupyter" not in raw:
              continue

          # Los argumentos estan separados por \0 en /proc/pid/cmdline
          parts = raw.split("\0")
          port_found = False
          for part in parts:
              if part in (f"--port={port}", f"--ServerApp.port={port}"):
                  port_found = True
                  break

          # Para el puerto default 8888, el proceso puede no tener --port explícito.
          # En ese caso verificamos que sea un proceso jupyter y no tenga otro puerto.
          if not port_found and port == 8888:
              has_other_port = any(
                  p.startswith("--port=") or p.startswith("--ServerApp.port=")
                  for p in parts
              )
              if not has_other_port and any("jupyter" in p for p in parts):
                  port_found = True

          if port_found:
              try:
                  return int(pid_entry.name)
              except ValueError:
                  continue

      return None


  def _get_root_dir_from_proc(pid: int) -> str:
      """Extrae el root_dir del proceso Jupyter a partir de su cmdline en /proc.

      Busca --ServerApp.root_dir= o --notebook-dir= en los argumentos del proceso.
      Si no los encuentra, usa el cwd del proceso como fallback.
      Retorna cadena vacia si no puede leer /proc.
      """
      try:
          cmdline_path = f"/proc/{pid}/cmdline"
          with open(cmdline_path, "rb") as f:
              parts = f.read().decode("utf-8", errors="replace").split("\0")
          for part in parts:
              if part.startswith("--ServerApp.root_dir="):
                  return part.split("=", 1)[1].rstrip("/")
              if part.startswith("--notebook-dir="):
                  return part.split("=", 1)[1].rstrip("/")
          # Fallback: cwd del proceso
          cwd = os.readlink(f"/proc/{pid}/cwd")
          return cwd.rstrip("/")
      except OSError:
          return ""


  def _extract_analysis_name(root_dir: str) -> str:
      """Extrae el nombre del analisis del root_dir.

      Si root_dir contiene 'analysis/{nombre}', retorna '{nombre}'.
      En caso contrario retorna el ultimo componente del path.
      """
      if not root_dir:
          return ""
      parts = root_dir.replace("\\", "/").split("/")
      # Buscar el segmento 'analysis' y tomar el siguiente
      for i, part in enumerate(parts):
          if part == "analysis" and i + 1 < len(parts) and parts[i + 1]:
              return parts[i + 1]
      # Fallback: ultimo componente del path
      return parts[-1] if parts else ""


  def _query_instance(base_url: str, port: int) -> dict | None:
      """Consulta la API REST de una instancia Jupyter y retorna su estado.

      Detecta el root_dir real del proceso via /proc (Linux) para identificar
      correctamente el analisis que esta sirviendo.

      Retorna None si la instancia no responde o no es Jupyter.
      """
      status = _get(f"{base_url}/api/status")
      if status is None:
          return None

      config = _get(f"{base_url}/api/config")
      kernels_raw = _get(f"{base_url}/api/kernels") or []
      sessions_raw = _get(f"{base_url}/api/sessions") or []

      # Detectar root_dir via /proc
      root_dir = ""
      pid = _find_jupyter_pid_for_port(port)
      if pid is not None:
          root_dir = _get_root_dir_from_proc(pid)

      kernels = []
      if isinstance(kernels_raw, list):
          for k in kernels_raw:
              if isinstance(k, dict):
                  kernels.append({
                      "id": k.get("id", ""),
                      "name": k.get("name", ""),
                      "execution_state": k.get("execution_state", ""),
                      "last_activity": k.get("last_activity", ""),
                  })

      sessions = []
      if isinstance(sessions_raw, list):
          for s in sessions_raw:
              if isinstance(s, dict):
                  kernel = s.get("kernel") or {}
                  path = s.get("path") or s.get("notebook", {}).get("path", "")
                  sessions.append({
                      "notebook": path,
                      "kernel_id": kernel.get("id", ""),
                      "kernel_state": kernel.get("execution_state", ""),
                  })

      return {
          "root_dir": root_dir,
          "kernels": kernels,
          "sessions": sessions,
          "collaborative": _is_collaborative(config),
      }


  def _scan_analysis_ports(registry_root: str) -> list[tuple[int, str]]:
      """Escanea subdirectorios de analysis/ buscando archivos .jupyter-port.

      Retorna lista de (puerto, nombre_analisis).
      """
      root = Path(registry_root) if registry_root else Path.cwd()
      analysis_dir = root / "analysis"
      results: list[tuple[int, str]] = []

      if not analysis_dir.is_dir():
          return results

      for entry in analysis_dir.iterdir():
          if not entry.is_dir():
              continue
          port_file = entry / ".jupyter-port"
          if port_file.is_file():
              try:
                  port = int(port_file.read_text().strip())
                  results.append((port, entry.name))
              except (ValueError, OSError):
                  pass

      return results


  def jupyter_discover(
      registry_root: str = "",
      ports: list[int] | None = None,
  ) -> list[dict]:
      """Descubre instancias de Jupyter Lab activas consultando su API REST.

      Escanea primero los archivos .jupyter-port en subdirectorios de analysis/
      para encontrar puertos registrados, y luego aplica un fallback sobre puertos
      comunes (8888-8892). Para cada instancia que responde consulta /api/status,
      /api/config, /api/kernels y /api/sessions.

      En Linux detecta el root_dir real del proceso Jupyter via /proc/pid/cmdline,
      lo que permite identificar correctamente el analisis en escenarios
      multi-instancia donde varios Jupyter corren en puertos distintos.

      Args:
          registry_root: Raiz del fn_registry. Si vacio usa el directorio actual
                         o la variable de entorno FN_REGISTRY_ROOT.
          ports: Lista de puertos a escanear. Si None, usa los puertos encontrados
                 en .jupyter-port mas los defaults (8888-8892).

      Returns:
          Lista de dicts con: url, port, analysis, root_dir, collaborative,
          kernels, sessions. Cada sesion incluye: notebook, kernel_id,
          kernel_state.
      """
      if not registry_root:
          registry_root = os.environ.get("FN_REGISTRY_ROOT", "")

      # Recopilar puertos a escanear
      port_analysis: dict[int, str] = {}

      if ports is not None:
          for p in ports:
              port_analysis[p] = ""
      else:
          # Primero los registrados en .jupyter-port
          for port, analysis_name in _scan_analysis_ports(registry_root):
              port_analysis[port] = analysis_name
          # Fallback: puertos comunes que no estén ya en la lista
          for p in _DEFAULT_PORTS:
              if p not in port_analysis:
                  port_analysis[p] = ""

      results = []
      for port, analysis_hint in port_analysis.items():
          base_url = f"http://localhost:{port}"
          info = _query_instance(base_url, port)
          if info is None:
              continue

          # Determinar analysis name: preferir deteccion via /proc sobre .jupyter-port
          root_dir = info["root_dir"]
          if root_dir:
              analysis_name = _extract_analysis_name(root_dir)
          else:
              # Fallback al hint del .jupyter-port
              analysis_name = analysis_hint

          results.append({
              "url": base_url,
              "port": port,
              "analysis": analysis_name,
              "root_dir": root_dir,
              "collaborative": info["collaborative"],
              "kernels": info["kernels"],
              "sessions": info["sessions"],
          })

      return results


  # ---------------------------------------------------------------------------
  # CLI
  # ---------------------------------------------------------------------------

  if __name__ == "__main__":
      import argparse
      import sys

      parser = argparse.ArgumentParser(
          description="Descubre instancias de Jupyter Lab activas."
      )
      parser.add_argument(
          "--registry-root",
          default="",
          help="Raiz del fn_registry (default: FN_REGISTRY_ROOT env o cwd)",
      )
      parser.add_argument(
          "--port",
          dest="ports",
          type=int,
          action="append",
          metavar="PORT",
          help="Puerto a escanear (puede repetirse). Default: .jupyter-port + 8888-8892",
      )
      parser.add_argument(
          "--json",
          action="store_true",
          help="Emitir salida en JSON",
      )
      args = parser.parse_args()

      instances = jupyter_discover(
          registry_root=args.registry_root,
          ports=args.ports,
      )

      if args.json:
          print(json.dumps(instances, indent=2))
          sys.exit(0)

      if not instances:
          print("No se encontraron instancias de Jupyter Lab activas.")
          sys.exit(0)

      for inst in instances:
          collab = "colaborativo" if inst["collaborative"] else "estandar"
          analysis = inst["analysis"] or "(desconocido)"
          root_dir = inst["root_dir"] or "(no detectado)"
          print(f"Puerto {inst['port']}  [{collab}]")
          print(f"  url:      {inst['url']}")
          print(f"  analysis: {analysis}")
          print(f"  root_dir: {root_dir}")
          kernel_count = len(inst["kernels"])
          if inst["kernels"]:
              print(f"  kernels ({kernel_count}):")
              for k in inst["kernels"]:
                  print(f"    - {k['name']}  estado={k['execution_state']}  id={k['id'][:8]}...")
          else:
              print("  kernels: ninguno")
          if inst["sessions"]:
              print(f"  sesiones ({len(inst['sessions'])}):")
              for s in inst["sessions"]:
                  kid = s["kernel_id"][:8] + "..." if s["kernel_id"] else "(sin kernel)"
                  print(f"    - {s['notebook']}  kernel={kid}  estado={s['kernel_state']}")
          else:
              print("  sesiones: ninguna")
          print()

params_schema:
  {"params":[{"name":"registry_root","desc":"Raíz del registry para detectar análisis (opcional, usa FN_REGISTRY_ROOT si no se proporciona)"},{"name":"ports","desc":"Puertos a escanear (default: 8888-8892, detecta automáticamente)"}],"output":"Lista de dicts con información de instancias Jupyter: url, port, analysis, root_dir, collaborative, kernels, sessions"}


id: jupyter_exec_py_notebook
name: jupyter_exec
kind: function
lang: py
domain: notebook
version: 1.0.0
purity: impure
signature: jupyter_append_execute(notebook_path: str, code: str, server_url: str, token: str) -> dict
tags: ["jupyter","notebook","kernel","websocket","execution","cells"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["jupyter_kernel_client","jupyter_nbmodel_client"]
tests: []
file_path: python/functions/notebook/jupyter_exec.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-07T20:58:02Z
props: []
emits: []
variant: []
content_hash: 4b0217f5f6d1a505120c67f69ed8b294007cd11808dc043719d579479c1b1c15
description:
  Ejecuta codigo en kernels de Jupyter via WebSocket. Tres modos: append (añade celda al notebook y la ejecuta), cell (ejecuta celda existente por indice), kernel (ejecuta en el kernel sin tocar ningun notebook).

notes:
  - Las funciones `append` y `cell` son async internamente; las publicas usan `asyncio.run()`.
  - `jupyter_kernel_execute` es sincrona directamente porque `KernelClient.execute` es bloqueante.
  - El token puede ser cadena vacia si el servidor tiene autenticacion deshabilitada.
  - `NbModelClient` requiere que el servidor tenga habilitado el endpoint colaborativo (`/api/collaboration/`), disponible en JupyterLab >= 4 con `jupyter-collaboration` instalado.
  - **Auto-init**: `jupyter_append_execute` crea el notebook automaticamente si no existe (via REST PUT /api/contents) y arranca una sesion con kernel si no hay ninguna activa para ese notebook (via POST /api/sessions). No es necesario abrir el notebook manualmente en el navegador.
  - **Auto-session**: `jupyter_execute_cell` tambien garantiza que exista una sesion con kernel antes de ejecutar.
  - **Fix Issue 006**: `jupyter_execute_cell` normaliza la celda antes de ejecutar. Las celdas creadas manualmente (no via la UI de Jupyter) pueden carecer de `outputs` o `execution_count` en el modelo CRDT, lo que causaba `KeyError: 'outputs'` dentro de `execute_cell` al hacer `del ycell["outputs"][:]`. El fix lee la celda con `nb[cell_index]`, detecta los campos faltantes, y reemplaza la celda via `nb[cell_index] = _normalize_code_cell(cell)` — que usa `set_cell` internamente para re-crear el mapa CRDT completo preservando el source original.

documentation:
  ## Funciones

  ### `jupyter_append_execute(notebook_path, code, server_url, token)`

  Añade una celda de codigo al final del notebook y la ejecuta. Usa el protocolo
  colaborativo de Jupyter, por lo que tanto el agente como el usuario ven la celda
  y su output en tiempo real en JupyterLab.

  ```python
  from notebook.jupyter_exec import jupyter_append_execute

  result = jupyter_append_execute(
      "notebooks/analisis.ipynb",
      "import pandas as pd\nprint(pd.__version__)",
      server_url="http://localhost:8888",
      token="",
  )
  # {"cell_index": 5, "outputs": ["2.2.1"]}
  ```

  ### `jupyter_execute_cell(notebook_path, cell_index, server_url, token)`

  Ejecuta una celda existente del notebook por su indice (0-based).

  ```python
  from notebook.jupyter_exec import jupyter_execute_cell

  result = jupyter_execute_cell("notebooks/analisis.ipynb", 3)
  # {"cell_index": 3, "outputs": ["42"]}
  ```

  ### `jupyter_kernel_execute(code, server_url, token)`

  Ejecuta codigo directamente en el kernel sin modificar ningun notebook. Util para
  consultas rapidas, inspeccion de variables o verificacion de estado del kernel.

  ```python
  from notebook.jupyter_exec import jupyter_kernel_execute

  result = jupyter_kernel_execute("len(df)")
  # {"outputs": ["1500"], "status": "ok"}
  ```

  ## CLI

  ```bash
  # Añadir celda y ejecutar
  python -m notebook.jupyter_exec append notebooks/mi.ipynb "print('hola')" --server http://localhost:8888 --token mytoken

  # Ejecutar celda existente
  python -m notebook.jupyter_exec cell notebooks/mi.ipynb 2 --server http://localhost:8888

  # Ejecutar en kernel directamente
  python -m notebook.jupyter_exec kernel "x = 42; print(x)"
  ```

  Output siempre JSON. En error retorna `{"error": "..."}` por stderr con exit code 1.

  ## Extraccion de outputs

  | output_type | campo leido |
  |---|---|
  | stream | `text` |
  | display_data / execute_result | `data.text/plain` |
  | error | `traceback` (joined con `\n`) |

code:
  """Ejecuta codigo en kernels de Jupyter via WebSocket.

  Tres modos de ejecucion:
  - append: añade una celda al final del notebook y la ejecuta
  - cell: ejecuta una celda existente por indice
  - kernel: ejecuta codigo directamente en el kernel sin modificar ningun notebook
  """

  import asyncio
  import json
  from functools import partial
  from typing import Any
  from urllib.error import HTTPError, URLError
  from urllib.request import Request, urlopen

  from jupyter_kernel_client import KernelClient
  from jupyter_nbmodel_client import NbModelClient, get_jupyter_notebook_websocket_url
  from nbformat import NotebookNode


  # ---------------------------------------------------------------------------
  # Helpers internos
  # ---------------------------------------------------------------------------


  def _notebook_exists(notebook_path: str, server_url: str, token: str) -> bool:
      """Comprueba si un notebook existe en el servidor Jupyter via HEAD /api/contents."""
      headers = {"Accept": "application/json"}
      if token:
          headers["Authorization"] = f"token {token}"
      check_url = f"{server_url}/api/contents/{notebook_path}"
      req = Request(check_url, headers=headers, method="HEAD")
      try:
          with urlopen(req, timeout=5):
              return True
      except HTTPError as e:
          if e.code == 404:
              return False
          raise


  def _create_notebook(notebook_path: str, server_url: str, token: str, kernel_name: str = "python3") -> None:
      """Crea un notebook vacio via PUT /api/contents si no existe."""
      if _notebook_exists(notebook_path, server_url, token):
          return
      headers = {
          "Content-Type": "application/json",
          "Accept": "application/json",
      }
      if token:
          headers["Authorization"] = f"token {token}"
      kernel_display = {"python3": "Python 3 (ipykernel)", "python": "Python 3"}.get(kernel_name, kernel_name)
      notebook_content = {
          "nbformat": 4,
          "nbformat_minor": 5,
          "metadata": {
              "kernelspec": {"name": kernel_name, "display_name": kernel_display, "language": "python"},
              "language_info": {"name": "python"},
          },
          "cells": [],
      }
      body = json.dumps({"type": "notebook", "content": notebook_content}).encode("utf-8")
      url = f"{server_url}/api/contents/{notebook_path}"
      req = Request(url, data=body, headers=headers, method="PUT")
      with urlopen(req, timeout=10) as resp:
          resp.read()


  def _ensure_session(server_url: str, token: str, notebook_path: str, kernel_name: str = "python3") -> str:
      """Garantiza que exista una sesion para el notebook. Retorna el kernel_id.

      Si ya hay una sesion activa, retorna su kernel_id. Si no, crea una nueva
      via POST /api/sessions (lo cual tambien arranca un kernel).
      """
      kernel_id = _resolve_kernel_id(server_url, token, notebook_path)
      if kernel_id:
          return kernel_id

      headers = {
          "Accept": "application/json",
          "Content-Type": "application/json",
      }
      if token:
          headers["Authorization"] = f"token {token}"

      body = json.dumps({
          "path": notebook_path,
          "type": "notebook",
          "kernel": {"name": kernel_name},
      }).encode("utf-8")

      url = f"{server_url}/api/sessions"
      req = Request(url, data=body, headers=headers, method="POST")
      with urlopen(req, timeout=10) as resp:
          session = json.loads(resp.read())

      return session.get("kernel", {}).get("id", "")


  def _api_get(url: str, token: str = "") -> dict | list | None:
      """GET a Jupyter REST API endpoint."""
      headers = {"Accept": "application/json"}
      if token:
          headers["Authorization"] = f"token {token}"
      try:
          req = Request(url, headers=headers)
          with urlopen(req, timeout=5) as resp:
              return json.loads(resp.read())
      except (URLError, OSError, json.JSONDecodeError):
          return None


  def _resolve_kernel_id(server_url: str, token: str, notebook_path: str) -> str | None:
      """Find the kernel_id associated with a notebook via the sessions API."""
      sessions = _api_get(f"{server_url}/api/sessions", token) or []
      for session in sessions:
          nb = session.get("notebook", session.get("path", {}))
          nb_path = nb.get("path", nb) if isinstance(nb, dict) else str(nb)
          if nb_path == notebook_path:
              kernel = session.get("kernel", {})
              return kernel.get("id")
      return None


  def _resolve_collab_username(server_url: str, token: str) -> str:
      """Resolve the display name of the active user in Jupyter collaboration.

      Queries /api/me to get the identity Jupyter assigned to the browser user.
      Falls back to 'Anonymous' if unavailable.
      """
      me = _api_get(f"{server_url}/api/me", token)
      if me:
          identity = me.get("identity", {})
          return identity.get("display_name", "") or identity.get("username", "") or identity.get("name", "Anonymous")
      return "Anonymous"


  def _normalize_code_cell(cell: NotebookNode) -> dict:
      """Devuelve un dict de celda de codigo con todos los campos requeridos por nbformat.

      Celdas creadas manualmente (no via Jupyter UI) pueden omitir 'outputs' o
      'execution_count'. El modelo CRDT de jupyter_nbmodel_client accede a estos
      campos sin comprobar su existencia, produciendo KeyError al ejecutar.
      Este helper garantiza que el dict tenga la estructura completa.
      """
      return {
          "id": cell.get("id", ""),
          "cell_type": "code",
          "metadata": cell.get("metadata", {}),
          "source": cell.get("source", ""),
          "outputs": cell.get("outputs", []),
          "execution_count": cell.get("execution_count", None),
      }


  def _extract_outputs(raw_outputs: list[dict]) -> list[str]:
      """Convierte outputs de nbformat a lista de strings legibles."""
      result: list[str] = []
      for output in raw_outputs:
          output_type = output.get("output_type", "")
          if output_type == "stream":
              text = output.get("text", "")
              if isinstance(text, list):
                  text = "".join(text)
              result.append(text.rstrip("\n"))
          elif output_type in ("display_data", "execute_result"):
              data = output.get("data", {})
              text = data.get("text/plain", "")
              if isinstance(text, list):
                  text = "".join(text)
              result.append(text.rstrip("\n"))
          elif output_type == "error":
              traceback = output.get("traceback", [])
              result.append("\n".join(traceback))
      return result


  # ---------------------------------------------------------------------------
  # Modo append (async interno)
  # ---------------------------------------------------------------------------


  async def _async_append_execute(
      notebook_path: str,
      code: str,
      server_url: str,
      token: str,
  ) -> dict[str, Any]:
      _create_notebook(notebook_path, server_url, token)
      kernel_id = _ensure_session(server_url, token, notebook_path)

      ws_url = get_jupyter_notebook_websocket_url(
          server_url,
          notebook_path,
          token or None,
      )
      username = _resolve_collab_username(server_url, token)

      async with NbModelClient(ws_url, username=username) as nb:
          await nb.wait_until_synced()

          with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
              cell_index = nb.add_code_cell(code)
              loop = asyncio.get_event_loop()
              result = await loop.run_in_executor(
                  None, partial(nb.execute_cell, cell_index, kernel),
              )

          # Let Y.js propagate changes to other clients (browser)
          await asyncio.sleep(2)

      outputs = _extract_outputs(result.get("outputs", []))
      return {"cell_index": cell_index, "outputs": outputs}


  # ---------------------------------------------------------------------------
  # Modo cell (async interno)
  # ---------------------------------------------------------------------------


  async def _async_execute_cell(
      notebook_path: str,
      cell_index: int,
      server_url: str,
      token: str,
  ) -> dict[str, Any]:
      kernel_id = _ensure_session(server_url, token, notebook_path)

      ws_url = get_jupyter_notebook_websocket_url(
          server_url,
          notebook_path,
          token or None,
      )
      username = _resolve_collab_username(server_url, token)

      async with NbModelClient(ws_url, username=username) as nb:
          await nb.wait_until_synced()

          # Normalizar la celda antes de ejecutar. Las celdas creadas manualmente
          # (sin pasar por la UI de Jupyter) pueden carecer de los campos 'outputs'
          # o 'execution_count' en el modelo CRDT, lo que provoca KeyError dentro
          # de execute_cell al intentar hacer `del ycell["outputs"][:]`.
          # Reemplazar la celda via __setitem__ fuerza la re-creacion completa del
          # mapa CRDT con todos los campos requeridos por nbformat.
          cell = nb[cell_index]
          if cell.get("cell_type") == "code" and (
              "outputs" not in cell or "execution_count" not in cell
          ):
              nb[cell_index] = _normalize_code_cell(cell)

          with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
              loop = asyncio.get_event_loop()
              result = await loop.run_in_executor(
                  None, partial(nb.execute_cell, cell_index, kernel),
              )

          await asyncio.sleep(2)

      outputs = _extract_outputs(result.get("outputs", []))
      return {"cell_index": cell_index, "outputs": outputs}


  # ---------------------------------------------------------------------------
  # API publica
  # ---------------------------------------------------------------------------


  def jupyter_append_execute(
      notebook_path: str,
      code: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict[str, Any]:
      """Añade una celda de codigo al final del notebook y la ejecuta.

      Tanto el agente como el usuario ven la celda y su output en tiempo real
      porque la escritura se realiza a traves del protocolo colaborativo de Jupyter.

      Args:
          notebook_path: Ruta al notebook relativa a la raiz del servidor Jupyter.
          code: Codigo Python a insertar y ejecutar.
          server_url: URL del servidor Jupyter; por defecto http://localhost:8888.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con 'cell_index' (indice de la nueva celda) y 'outputs' (lista de strings).

      Raises:
          Exception: si no se puede conectar al servidor o al kernel.
      """
      return asyncio.run(_async_append_execute(notebook_path, code, server_url, token))


  def jupyter_execute_cell(
      notebook_path: str,
      cell_index: int,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict[str, Any]:
      """Ejecuta una celda existente del notebook por indice.

      Args:
          notebook_path: Ruta al notebook relativa a la raiz del servidor Jupyter.
          cell_index: Indice de la celda a ejecutar (0-based).
          server_url: URL del servidor Jupyter; por defecto http://localhost:8888.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con 'cell_index' y 'outputs' (lista de strings).

      Raises:
          IndexError: si cell_index esta fuera de rango.
          Exception: si no se puede conectar al servidor o al kernel.
      """
      return asyncio.run(_async_execute_cell(notebook_path, cell_index, server_url, token))


  def jupyter_kernel_execute(
      code: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict[str, Any]:
      """Ejecuta codigo directamente en el kernel sin modificar ningun notebook.

      Util para consultas rapidas, inspeccion de variables, comprobaciones de estado.

      Args:
          code: Codigo Python a ejecutar en el kernel activo.
          server_url: URL del servidor Jupyter; por defecto http://localhost:8888.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con 'outputs' (lista de strings) y 'status' ('ok' o 'error').

      Raises:
          Exception: si no se puede conectar al servidor o al kernel.
      """
      with KernelClient(server_url=server_url, token=token) as kernel:
          result = kernel.execute(code)

      outputs = _extract_outputs(result.get("outputs", []))
      return {"outputs": outputs, "status": result.get("status", "unknown")}


  # ---------------------------------------------------------------------------
  # CLI
  # ---------------------------------------------------------------------------

  if __name__ == "__main__":
      import argparse
      import sys

      parser = argparse.ArgumentParser(
          description="Ejecuta codigo en kernels de Jupyter",
      )
      sub = parser.add_subparsers(dest="command", required=True)

      # append
      p_append = sub.add_parser("append", help="Añade celda al notebook y la ejecuta")
      p_append.add_argument("notebook", help="Ruta al notebook relativa al servidor")
      p_append.add_argument("code", help="Codigo a insertar y ejecutar")
      p_append.add_argument("--server", default="http://localhost:8888")
      p_append.add_argument("--token", default="")

      # cell
      p_cell = sub.add_parser("cell", help="Ejecuta celda existente por indice")
      p_cell.add_argument("notebook", help="Ruta al notebook relativa al servidor")
      p_cell.add_argument("index", type=int, help="Indice de la celda (0-based)")
      p_cell.add_argument("--server", default="http://localhost:8888")
      p_cell.add_argument("--token", default="")

      # kernel
      p_kernel = sub.add_parser("kernel", help="Ejecuta codigo en el kernel sin tocar notebook")
      p_kernel.add_argument("code", help="Codigo a ejecutar")
      p_kernel.add_argument("--server", default="http://localhost:8888")
      p_kernel.add_argument("--token", default="")

      args = parser.parse_args()

      try:
          if args.command == "append":
              result = jupyter_append_execute(args.notebook, args.code, args.server, args.token)
          elif args.command == "cell":
              result = jupyter_execute_cell(args.notebook, args.index, args.server, args.token)
          elif args.command == "kernel":
              result = jupyter_kernel_execute(args.code, args.server, args.token)
          else:
              parser.print_help()
              sys.exit(1)

          print(json.dumps(result, ensure_ascii=False, indent=2))
      except Exception as exc:
          print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
          sys.exit(1)

params_schema:
  {"params":[{"name":"notebook_path","desc":"Ruta relativa al notebook"},{"name":"code","desc":"Código a ejecutar"},{"name":"server_url","desc":"URL del servidor Jupyter (default localhost:8888)"},{"name":"token","desc":"Token de autenticación (default vacío)"}],"output":"Dict con cell_index y outputs del código ejecutado, o resultados del kernel"}


id: jupyter_kernel_py_notebook
name: jupyter_kernel
kind: function
lang: py
domain: notebook
version: 1.0.0
purity: impure
signature: def jupyter_kernel_list(server_url: str = "http://localhost:8888", token: str = "") -> list[dict]
tags: ["jupyter","notebook","kernel","api","http","rest","sessions","crud","cleanup"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["json","urllib.error","urllib.request"]
tests: []
file_path: python/functions/notebook/jupyter_kernel.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-07T20:58:02Z
props: []
emits: []
variant: []
content_hash: 0342f5112c540bc035f6173a4f95b5ce1568d762838bf1be0cd67d00cec46546
description:
  CRUD completo de kernels Jupyter via REST API. Expone ocho operaciones: list, start, restart, interrupt, shutdown, sessions, cleanup y shutdown-all. Usa solo stdlib (urllib, json), sin dependencias externas.

example:
  ```python
  from notebook.jupyter_kernel import (
      jupyter_kernel_list,
      jupyter_kernel_start,
      jupyter_kernel_restart,
      jupyter_kernel_interrupt,
      jupyter_kernel_shutdown,
      jupyter_kernel_sessions,
  )

  # Listar kernels activos
  kernels = jupyter_kernel_list("http://localhost:8888", token="mi_token")
  # [{"id": "abc123", "name": "python3", "execution_state": "idle",
  #   "last_activity": "2026-04-01T10:00:00.000Z", "connections": 1}]

  # Iniciar un kernel nuevo
  kernel = jupyter_kernel_start("http://localhost:8888", token="mi_token", name="python3")
  # {"id": "def456", "name": "python3", "execution_state": "starting"}

  # Reiniciar
  jupyter_kernel_restart("http://localhost:8888", "mi_token", kernel["id"])

  # Interrumpir ejecucion en curso
  jupyter_kernel_interrupt("http://localhost:8888", "mi_token", kernel["id"])

  # Apagar
  jupyter_kernel_shutdown("http://localhost:8888", "mi_token", kernel["id"])

  # Listar sesiones (mapeo notebook <-> kernel)
  sessions = jupyter_kernel_sessions("http://localhost:8888", token="mi_token")
  # [{"id": "s1", "notebook": "notebooks/01_analisis.ipynb",
  #   "kernel_id": "abc123", "kernel_state": "idle", "type": "notebook", "name": "01_analisis"}]
  ```

notes:
  Solo usa stdlib: `urllib.request`, `urllib.error`, `json`. No requiere `requests`, `jupyter_client` ni ningun paquete externo.

  La funcion interna `_make_request` centraliza la construccion de headers y serializacion JSON. El header `Authorization: token {token}` se omite si `token` esta vacio, permitiendo conectar a servidores sin autenticacion.

  `jupyter_kernel_interrupt` y `jupyter_kernel_shutdown` retornan `None` porque la API de Jupyter devuelve 204 No Content en esos casos.

  Compatible con Jupyter Lab 3.x, 4.x y Jupyter Notebook 6.x/7.x — todos exponen la misma REST API en `/api/kernels` y `/api/sessions`.

documentation:
  ## Funciones expuestas

  | Funcion | Endpoint | Descripcion |
  |---------|----------|-------------|
  | `jupyter_kernel_list(server_url, token)` | `GET /api/kernels` | Lista kernels activos |
  | `jupyter_kernel_start(server_url, token, name)` | `POST /api/kernels` | Inicia un kernel nuevo |
  | `jupyter_kernel_restart(server_url, token, kernel_id)` | `POST /api/kernels/{id}/restart` | Reinicia un kernel |
  | `jupyter_kernel_interrupt(server_url, token, kernel_id)` | `POST /api/kernels/{id}/interrupt` | Interrumpe ejecucion |
  | `jupyter_kernel_shutdown(server_url, token, kernel_id)` | `DELETE /api/kernels/{id}` | Apaga y elimina un kernel |
  | `jupyter_kernel_sessions(server_url, token)` | `GET /api/sessions` | Lista sesiones activas |
  | `jupyter_kernel_cleanup(server_url, token, idle_seconds)` | `GET + DELETE` | Apaga kernels inactivos |
  | `jupyter_kernel_shutdown_all(server_url, token)` | `GET + DELETE` | Apaga todos los kernels |

  ## CLI

  ```bash
  # Listar kernels
  python python/functions/notebook/jupyter_kernel.py list --server http://localhost:8888 --token TK

  # Iniciar kernel (por defecto python3)
  python python/functions/notebook/jupyter_kernel.py start --name python3

  # Reiniciar
  python python/functions/notebook/jupyter_kernel.py restart abc123-...

  # Interrumpir
  python python/functions/notebook/jupyter_kernel.py interrupt abc123-...

  # Apagar
  python python/functions/notebook/jupyter_kernel.py shutdown abc123-...

  # Listar sesiones
  python python/functions/notebook/jupyter_kernel.py sessions

  # Limpiar kernels inactivos (default: 1h sin actividad)
  python python/functions/notebook/jupyter_kernel.py cleanup --idle-seconds 1800

  # Apagar todos los kernels
  python python/functions/notebook/jupyter_kernel.py shutdown-all
  ```

  Todos los subcomandos aceptan `--server` y `--token`. El output es siempre JSON.

  ## Estructura de los dicts retornados

  **`jupyter_kernel_list`** — cada elemento:
  ```python
  {
      "id": "abc123-...",
      "name": "python3",
      "execution_state": "idle",   # idle | busy | starting
      "last_activity": "2026-04-01T10:00:00.000Z",
      "connections": 1
  }
  ```

  **`jupyter_kernel_start`** — elemento retornado:
  ```python
  {
      "id": "def456-...",
      "name": "python3",
      "execution_state": "starting"
  }
  ```

  **`jupyter_kernel_sessions`** — cada elemento:
  ```python
  {
      "id": "session-id",
      "notebook": "notebooks/01_analisis.ipynb",  # path relativo al root del servidor
      "kernel_id": "abc123-...",
      "kernel_state": "idle",
      "type": "notebook",
      "name": "01_analisis"
  }
  ```

code:
  """CRUD de kernels Jupyter via REST API."""

  import json
  import urllib.error
  import urllib.request


  def _make_request(
      method: str,
      url: str,
      token: str = "",
      body: dict | None = None,
  ) -> dict | list | None:
      """Ejecuta una request HTTP a la API de Jupyter.

      Args:
          method: Metodo HTTP (GET, POST, DELETE).
          url: URL completa del endpoint.
          token: Token de autenticacion de Jupyter. Vacio si no se requiere.
          body: Cuerpo de la request para metodos POST.

      Returns:
          Respuesta deserializada como dict o list, o None si la respuesta esta vacia.

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error.
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      data = json.dumps(body).encode("utf-8") if body is not None else None

      headers = {
          "Accept": "application/json",
          "Content-Type": "application/json",
      }
      if token:
          headers["Authorization"] = f"token {token}"

      req = urllib.request.Request(url, data=data, headers=headers, method=method)

      with urllib.request.urlopen(req) as resp:
          raw = resp.read()
          if not raw:
              return None
          return json.loads(raw.decode("utf-8"))


  def jupyter_kernel_list(
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> list[dict]:
      """Lista todos los kernels activos en el servidor Jupyter.

      Args:
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion. Vacio si el servidor no requiere auth.

      Returns:
          Lista de dicts con los campos: id, name, execution_state,
          last_activity, connections.

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error.
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      url = f"{server_url.rstrip('/')}/api/kernels"
      result = _make_request("GET", url, token)
      return result if isinstance(result, list) else []


  def jupyter_kernel_start(
      server_url: str = "http://localhost:8888",
      token: str = "",
      name: str = "python3",
  ) -> dict:
      """Inicia un kernel nuevo en el servidor Jupyter.

      Args:
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion. Vacio si el servidor no requiere auth.
          name: Nombre del kernel a iniciar (p.ej. "python3", "ir").

      Returns:
          Dict con los campos: id, name, execution_state.

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error.
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      url = f"{server_url.rstrip('/')}/api/kernels"
      result = _make_request("POST", url, token, body={"name": name})
      return result if isinstance(result, dict) else {}


  def jupyter_kernel_restart(
      server_url: str = "http://localhost:8888",
      token: str = "",
      kernel_id: str = "",
  ) -> dict:
      """Reinicia un kernel existente.

      Args:
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion. Vacio si el servidor no requiere auth.
          kernel_id: ID del kernel a reiniciar.

      Returns:
          Dict con la informacion actualizada del kernel.

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error (p.ej. 404 si no existe).
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      url = f"{server_url.rstrip('/')}/api/kernels/{kernel_id}/restart"
      result = _make_request("POST", url, token)
      return result if isinstance(result, dict) else {}


  def jupyter_kernel_interrupt(
      server_url: str = "http://localhost:8888",
      token: str = "",
      kernel_id: str = "",
  ) -> None:
      """Interrumpe la ejecucion actual de un kernel.

      Args:
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion. Vacio si el servidor no requiere auth.
          kernel_id: ID del kernel a interrumpir.

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error (p.ej. 404 si no existe).
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      url = f"{server_url.rstrip('/')}/api/kernels/{kernel_id}/interrupt"
      _make_request("POST", url, token)


  def jupyter_kernel_shutdown(
      server_url: str = "http://localhost:8888",
      token: str = "",
      kernel_id: str = "",
  ) -> None:
      """Apaga y elimina un kernel.

      Args:
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion. Vacio si el servidor no requiere auth.
          kernel_id: ID del kernel a apagar.

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error (p.ej. 404 si no existe).
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      url = f"{server_url.rstrip('/')}/api/kernels/{kernel_id}"
      _make_request("DELETE", url, token)


  def jupyter_kernel_sessions(
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> list[dict]:
      """Lista las sesiones activas del servidor Jupyter.

      Cada sesion mapea un notebook a su kernel y usuario actuales.

      Args:
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion. Vacio si el servidor no requiere auth.

      Returns:
          Lista de dicts con los campos: id, notebook (path), kernel_id,
          kernel_state, type, name.

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error.
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      url = f"{server_url.rstrip('/')}/api/sessions"
      raw = _make_request("GET", url, token)
      if not isinstance(raw, list):
          return []

      sessions = []
      for s in raw:
          kernel = s.get("kernel") or {}
          sessions.append(
              {
                  "id": s.get("id", ""),
                  "notebook": s.get("path", ""),
                  "kernel_id": kernel.get("id", ""),
                  "kernel_state": kernel.get("execution_state", ""),
                  "type": s.get("type", ""),
                  "name": s.get("name", ""),
              }
          )
      return sessions


  def jupyter_kernel_cleanup(
      server_url: str = "http://localhost:8888",
      token: str = "",
      idle_seconds: int = 3600,
  ) -> list[dict]:
      """Apaga todos los kernels que llevan mas de idle_seconds sin actividad.

      Util para liberar recursos en servidores con muchos notebooks abiertos.
      Por defecto cierra kernels inactivos desde hace mas de 1 hora.

      Args:
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion. Vacio si el servidor no requiere auth.
          idle_seconds: Segundos de inactividad para considerar un kernel ocioso.

      Returns:
          Lista de dicts con los kernels apagados (id, name, last_activity, idle_seconds).

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error.
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      from datetime import datetime, timezone

      kernels = jupyter_kernel_list(server_url, token)
      now = datetime.now(timezone.utc)
      shutdown_list = []

      for k in kernels:
          last_activity = k.get("last_activity", "")
          if not last_activity:
              continue
          try:
              last_dt = datetime.fromisoformat(last_activity.replace("Z", "+00:00"))
              idle = (now - last_dt).total_seconds()
          except (ValueError, TypeError):
              continue
          if idle >= idle_seconds:
              jupyter_kernel_shutdown(server_url, token, k["id"])
              shutdown_list.append({
                  "id": k["id"],
                  "name": k.get("name", ""),
                  "last_activity": last_activity,
                  "idle_seconds": int(idle),
              })

      return shutdown_list


  def jupyter_kernel_shutdown_all(
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> list[dict]:
      """Apaga todos los kernels activos del servidor.

      Args:
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion. Vacio si el servidor no requiere auth.

      Returns:
          Lista de dicts con los kernels apagados (id, name).

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error.
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      kernels = jupyter_kernel_list(server_url, token)
      shutdown_list = []
      for k in kernels:
          jupyter_kernel_shutdown(server_url, token, k["id"])
          shutdown_list.append({"id": k["id"], "name": k.get("name", "")})
      return shutdown_list


  # ---------------------------------------------------------------------------
  # CLI
  # ---------------------------------------------------------------------------

  if __name__ == "__main__":
      import argparse
      import sys

      parser = argparse.ArgumentParser(
          description="CRUD de kernels Jupyter via REST API."
      )
      parser.add_argument(
          "--server",
          default="http://localhost:8888",
          help="URL base del servidor Jupyter (default: http://localhost:8888)",
      )
      parser.add_argument(
          "--token",
          default="",
          help="Token de autenticacion de Jupyter (default: vacio)",
      )

      subparsers = parser.add_subparsers(dest="command", required=True)

      # list
      subparsers.add_parser("list", help="Lista todos los kernels activos.")

      # start
      sp_start = subparsers.add_parser("start", help="Inicia un kernel nuevo.")
      sp_start.add_argument(
          "--name",
          default="python3",
          help="Nombre del kernel (default: python3)",
      )

      # restart
      sp_restart = subparsers.add_parser("restart", help="Reinicia un kernel existente.")
      sp_restart.add_argument("kernel_id", help="ID del kernel a reiniciar.")

      # interrupt
      sp_interrupt = subparsers.add_parser(
          "interrupt", help="Interrumpe la ejecucion de un kernel."
      )
      sp_interrupt.add_argument("kernel_id", help="ID del kernel a interrumpir.")

      # shutdown
      sp_shutdown = subparsers.add_parser("shutdown", help="Apaga y elimina un kernel.")
      sp_shutdown.add_argument("kernel_id", help="ID del kernel a apagar.")

      # sessions
      subparsers.add_parser("sessions", help="Lista las sesiones activas.")

      # cleanup
      sp_cleanup = subparsers.add_parser("cleanup", help="Apaga kernels inactivos.")
      sp_cleanup.add_argument(
          "--idle-seconds",
          type=int,
          default=3600,
          help="Segundos de inactividad para considerar ocioso (default: 3600)",
      )

      # shutdown-all
      subparsers.add_parser("shutdown-all", help="Apaga todos los kernels activos.")

      args = parser.parse_args()

      try:
          if args.command == "list":
              result = jupyter_kernel_list(args.server, args.token)
          elif args.command == "start":
              result = jupyter_kernel_start(args.server, args.token, args.name)
          elif args.command == "restart":
              result = jupyter_kernel_restart(args.server, args.token, args.kernel_id)
              if result is None:
                  result = {"status": "restarted", "kernel_id": args.kernel_id}
          elif args.command == "interrupt":
              jupyter_kernel_interrupt(args.server, args.token, args.kernel_id)
              result = {"status": "interrupted", "kernel_id": args.kernel_id}
          elif args.command == "shutdown":
              jupyter_kernel_shutdown(args.server, args.token, args.kernel_id)
              result = {"status": "shutdown", "kernel_id": args.kernel_id}
          elif args.command == "sessions":
              result = jupyter_kernel_sessions(args.server, args.token)
          elif args.command == "cleanup":
              result = jupyter_kernel_cleanup(args.server, args.token, args.idle_seconds)
          elif args.command == "shutdown-all":
              result = jupyter_kernel_shutdown_all(args.server, args.token)
          else:
              parser.print_help()
              sys.exit(1)

          print(json.dumps(result, indent=2))
      except urllib.error.HTTPError as e:
          body = e.read().decode("utf-8", errors="replace")
          print(
              json.dumps({"error": f"HTTP {e.code}: {e.reason}", "detail": body}),
              file=sys.stderr,
          )
          sys.exit(1)
      except urllib.error.URLError as e:
          print(
              json.dumps({"error": f"URLError: {e.reason}"}),
              file=sys.stderr,
          )
          sys.exit(1)

params_schema:
  {"output":"Múltiples funciones para CRUD de kernels: list, start, restart, interrupt, shutdown, sessions (retornan dicts con metadatos)"}


id: jupyter_read_py_notebook
name: jupyter_read
kind: function
lang: py
domain: notebook
version: 1.0.0
purity: impure
tags: ["jupyter","notebook","crdt","yjs","websocket","cells","read","realtime"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["jupyter_nbmodel_client"]
tests: []
file_path: python/functions/notebook/jupyter_read.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 1d2bf72fe0b605f452cb429d0f19b2478fd75f834b274c4e225c266f69529d69
signature:
  def jupyter_read_cells(notebook_path: str, server_url: str = 'http://localhost:8888', token: str = '', cell_index: int | None = None) -> list[dict]

description:
  Lee celdas de un notebook Jupyter abierto via el protocolo de colaboracion en tiempo real (CRDT/Y.js). Devuelve el estado actual incluyendo cambios no guardados. Expone tambien jupyter_notebook_info() para metadata rapida.

example:
  ```python
  from notebook.jupyter_read import jupyter_read_cells, jupyter_notebook_info

  # Leer todas las celdas
  cells = jupyter_read_cells(
      "notebooks/analysis.ipynb",
      server_url="http://localhost:8888",
      token="mi-token",
  )
  for cell in cells:
      print(f"[{cell['index']}] {cell['type']}: {cell['source'][:60]}")

  # Leer solo la celda 3
  cell = jupyter_read_cells("notebooks/analysis.ipynb", token="mi-token", cell_index=3)

  # Solo metadata
  info = jupyter_notebook_info("notebooks/analysis.ipynb", token="mi-token")
  print(f"Total celdas: {info['total_cells']}")
  ```

notes:
  - Usa `NbModelClient` de `jupyter_nbmodel_client` con protocolo CRDT/Y.js.
  - Las funciones internas son `async`; las publicas envuelven con `asyncio.run()` para ser sincronas.
  - Lee el estado **en memoria del servidor**, no el archivo `.ipynb` en disco — captura cambios no guardados.
  - `notebook_path` debe ser relativo a la raiz del servidor Jupyter, no al sistema de archivos local.
  - Para servidores sin token, usar `token=""` (default).
  - El CLI muestra preview de hasta 8 lineas de source y 4 lineas por output en modo legible.

documentation:
  ## Funciones exportadas

  ### `jupyter_read_cells`

  ```python
  def jupyter_read_cells(
      notebook_path: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
      cell_index: int | None = None,
  ) -> list[dict]
  ```

  Lee todas las celdas o una especifica de un notebook Jupyter en vivo.
  Retorna lista de dicts:

  ```python
  {"index": 0, "type": "code", "source": "import pandas as pd", "outputs": ["..."]}
  ```

  - Para celdas markdown y raw, el campo `outputs` no se incluye.
  - Para code cells, los outputs se convierten a texto legible:
    - `stream` -> texto plano
    - `display_data`/`execute_result` -> `text/plain`, o `[HTML Output]`, `[Image Output (PNG)]`
    - `error` -> traceback limpio (sin codigos ANSI)

  ### `jupyter_notebook_info`

  ```python
  def jupyter_notebook_info(
      notebook_path: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict
  ```

  Retorna metadata del notebook:

  ```python
  {
      "notebook_path": "notebooks/analysis.ipynb",
      "server_url": "http://localhost:8888",
      "total_cells": 12,
      "cell_counts": {"code": 9, "markdown": 3}
  }
  ```

  ## CLI

  ```bash
  # Ver todas las celdas (formato legible)
  python jupyter_read.py notebooks/analysis.ipynb --token MI_TOKEN

  # Ver solo la celda 5
  python jupyter_read.py notebooks/analysis.ipynb --token MI_TOKEN --cell 5

  # Solo metadata
  python jupyter_read.py notebooks/analysis.ipynb --token MI_TOKEN --info

  # Salida JSON (todas las celdas)
  python jupyter_read.py notebooks/analysis.ipynb --token MI_TOKEN --json

  # Servidor remoto
  python jupyter_read.py notebooks/analysis.ipynb --server http://mi-servidor:8888 --token MI_TOKEN
  ```

code:
  """Lee celdas de un notebook Jupyter via protocolo de colaboracion en tiempo real (CRDT/Y.js)."""

  from __future__ import annotations

  import asyncio
  import json
  import sys
  from typing import Any
  from urllib.error import URLError
  from urllib.request import Request, urlopen

  from jupyter_nbmodel_client import NbModelClient, get_jupyter_notebook_websocket_url


  def _resolve_collab_username(server_url: str, token: str) -> str:
      """Resolve the display name of the active user in Jupyter collaboration."""
      headers = {"Accept": "application/json"}
      if token:
          headers["Authorization"] = f"token {token}"
      try:
          req = Request(f"{server_url}/api/me", headers=headers)
          with urlopen(req, timeout=5) as resp:
              me = json.loads(resp.read())
          identity = me.get("identity", {})
          return identity.get("display_name", "") or identity.get("username", "") or identity.get("name", "Anonymous")
      except (URLError, OSError, json.JSONDecodeError):
          return "Anonymous"


  # ---------------------------------------------------------------------------
  # Helpers internos (async)
  # ---------------------------------------------------------------------------

  def _extract_outputs(outputs: list[dict]) -> list[str]:
      """Convierte outputs de celda en representaciones legibles."""
      result = []
      for out in outputs:
          out_type = out.get("output_type", "")
          if out_type == "stream":
              text = out.get("text", "")
              if isinstance(text, list):
                  text = "".join(text)
              result.append(text.rstrip())
          elif out_type in ("display_data", "execute_result"):
              data = out.get("data", {})
              if "text/plain" in data:
                  plain = data["text/plain"]
                  if isinstance(plain, list):
                      plain = "".join(plain)
                  result.append(plain.rstrip())
              elif "text/html" in data:
                  result.append("[HTML Output]")
              elif "image/png" in data:
                  result.append("[Image Output (PNG)]")
              else:
                  result.append(f"[Output: {list(data.keys())}]")
          elif out_type == "error":
              traceback = out.get("traceback", [])
              # Strip ANSI codes for clean text
              import re
              ansi_escape = re.compile(r"\x1b\[[0-9;]*m")
              clean = [ansi_escape.sub("", line) for line in traceback]
              result.append("\n".join(clean))
      return result


  def _cell_to_dict(index: int, cell: Any) -> dict:
      """Convierte una NotebookNode en un dict normalizado."""
      cell_type = cell.get("cell_type", "code")
      source = cell.get("source", "")
      if isinstance(source, list):
          source = "".join(source)

      entry: dict = {
          "index": index,
          "type": cell_type,
          "source": source,
      }

      if cell_type == "code":
          raw_outputs = cell.get("outputs", [])
          entry["outputs"] = _extract_outputs(raw_outputs)

      return entry


  async def _read_cells_async(
      notebook_path: str,
      server_url: str,
      token: str,
      cell_index: int | None,
  ) -> list[dict]:
      """Conecta al servidor Jupyter y lee las celdas del notebook."""
      ws_url = get_jupyter_notebook_websocket_url(
          server_url,
          notebook_path,
          token,
      )
      username = _resolve_collab_username(server_url, token)
      async with NbModelClient(ws_url, username=username) as client:
          await client.wait_until_synced()
          total = len(client)
          if cell_index is not None:
              if cell_index < 0 or cell_index >= total:
                  raise IndexError(
                      f"cell_index {cell_index} fuera de rango (0-{total - 1})"
                  )
              return [_cell_to_dict(cell_index, client[cell_index])]
          return [_cell_to_dict(i, client[i]) for i in range(total)]


  async def _notebook_info_async(
      notebook_path: str,
      server_url: str,
      token: str,
  ) -> dict:
      """Conecta al servidor Jupyter y retorna metadata del notebook."""
      ws_url = get_jupyter_notebook_websocket_url(
          server_url,
          notebook_path,
          token,
      )
      username = _resolve_collab_username(server_url, token)
      async with NbModelClient(ws_url, username=username) as client:
          await client.wait_until_synced()
          total = len(client)
          counts: dict[str, int] = {}
          for i in range(total):
              ct = client[i].get("cell_type", "code")
              counts[ct] = counts.get(ct, 0) + 1
          return {
              "notebook_path": notebook_path,
              "server_url": server_url,
              "total_cells": total,
              "cell_counts": counts,
          }


  # ---------------------------------------------------------------------------
  # API publica (sincrona)
  # ---------------------------------------------------------------------------

  def jupyter_read_cells(
      notebook_path: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
      cell_index: int | None = None,
  ) -> list[dict]:
      """Lee todas las celdas de un notebook Jupyter o una celda especifica.

      Conecta via el protocolo de colaboracion en tiempo real (CRDT/Y.js) y
      devuelve el estado actual del notebook incluyendo cambios no guardados.

      Args:
          notebook_path: Ruta relativa al notebook desde la raiz del servidor
                         (ej: "notebooks/analysis.ipynb").
          server_url: URL base del servidor Jupyter (default http://localhost:8888).
          token: Token de autenticacion del servidor Jupyter.
          cell_index: Si se indica, retorna solo esa celda (0-based). Si es None,
                      retorna todas las celdas.

      Returns:
          Lista de dicts con campos:
          - index (int): posicion de la celda en el notebook
          - type (str): "code", "markdown" o "raw"
          - source (str): contenido de la celda
          - outputs (list[str]): solo para code cells; representacion legible
            de cada output (stream, texto plano, [HTML Output], [Image Output (PNG)],
            traceback de errores).

      Raises:
          IndexError: Si cell_index esta fuera del rango del notebook.
          Exception: Si no se puede conectar al servidor Jupyter o al notebook.
      """
      return asyncio.run(
          _read_cells_async(notebook_path, server_url, token, cell_index)
      )


  def jupyter_notebook_info(
      notebook_path: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict:
      """Retorna metadata de un notebook Jupyter abierto.

      Args:
          notebook_path: Ruta relativa al notebook desde la raiz del servidor.
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion.

      Returns:
          Dict con:
          - notebook_path (str): ruta del notebook
          - server_url (str): URL del servidor
          - total_cells (int): numero total de celdas
          - cell_counts (dict): conteo por tipo {"code": N, "markdown": M, ...}

      Raises:
          Exception: Si no se puede conectar al servidor Jupyter o al notebook.
      """
      return asyncio.run(
          _notebook_info_async(notebook_path, server_url, token)
      )


  # ---------------------------------------------------------------------------
  # CLI
  # ---------------------------------------------------------------------------

  def _format_readable(cells: list[dict]) -> str:
      """Formatea celdas en texto legible con preview de hasta 8 lineas."""
      lines = []
      for cell in cells:
          header = f"[{cell['index']}] {cell['type'].upper()}"
          lines.append(header)
          lines.append("-" * len(header))
          source_lines = cell["source"].splitlines()
          preview = source_lines[:8]
          lines.extend(preview)
          if len(source_lines) > 8:
              lines.append(f"... ({len(source_lines) - 8} lineas mas)")
          if "outputs" in cell and cell["outputs"]:
              lines.append("  -> outputs:")
              for out in cell["outputs"]:
                  out_preview = out.splitlines()[:4]
                  for ol in out_preview:
                      lines.append(f"     {ol}")
                  if len(out.splitlines()) > 4:
                      lines.append("     ...")
          lines.append("")
      return "\n".join(lines)


  def main() -> None:
      import argparse

      parser = argparse.ArgumentParser(
          description="Lee celdas de un notebook Jupyter via CRDT/Y.js"
      )
      parser.add_argument("notebook", help="Ruta del notebook relativa al servidor")
      parser.add_argument(
          "--server",
          default="http://localhost:8888",
          help="URL del servidor Jupyter (default: http://localhost:8888)",
      )
      parser.add_argument("--token", default="", help="Token de autenticacion")
      parser.add_argument(
          "--cell",
          type=int,
          default=None,
          metavar="INDEX",
          help="Indice de celda especifica (0-based)",
      )
      parser.add_argument(
          "--info",
          action="store_true",
          help="Mostrar solo metadata del notebook",
      )
      parser.add_argument(
          "--json",
          action="store_true",
          dest="as_json",
          help="Salida en formato JSON",
      )
      args = parser.parse_args()

      try:
          if args.info:
              result = jupyter_notebook_info(args.notebook, args.server, args.token)
              if args.as_json:
                  print(json.dumps(result, ensure_ascii=False, indent=2))
              else:
                  print(f"Notebook: {result['notebook_path']}")
                  print(f"Servidor: {result['server_url']}")
                  print(f"Total celdas: {result['total_cells']}")
                  for ct, count in result["cell_counts"].items():
                      print(f"  {ct}: {count}")
          else:
              cells = jupyter_read_cells(
                  args.notebook, args.server, args.token, args.cell
              )
              if args.as_json:
                  print(json.dumps(cells, ensure_ascii=False, indent=2))
              else:
                  print(_format_readable(cells))
      except Exception as exc:
          print(f"Error: {exc}", file=sys.stderr)
          sys.exit(1)


  if __name__ == "__main__":
      main()

params_schema:
  {"params":[{"name":"notebook_path","desc":"Ruta relativa del notebook"},{"name":"server_url","desc":"URL del servidor Jupyter (default localhost:8888)"},{"name":"token","desc":"Token de autenticación"},{"name":"cell_index","desc":"Índice de celda específica a leer (opcional)"}],"output":"Lista de dicts con info de celdas (index, type, source, outputs) o dict con metadata del notebook"}


id: jupyter_write_py_notebook
name: jupyter_write
kind: function
lang: py
domain: notebook
version: 1.1.0
purity: impure
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["jupyter_nbmodel_client"]
tests: []
file_path: python/functions/notebook/jupyter_write.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-15T11:00:52Z
props: []
emits: []
variant: []
content_hash: 23b21f858cfbfa27fa47921d8db2963e32e7cb33ac5445fd386945b7e01ebee3
signature:
  def jupyter_append_code(notebook_path: str, source: str, server_url: str = 'http://localhost:8888', token: str = '') -> dict

description:
  Operaciones de escritura sobre celdas de un notebook Jupyter via colaboracion en tiempo real (WebSocket) y API REST. Expone siete operaciones: append_code, append_markdown, insert, edit, delete, create y batch. NO ejecuta celdas — solo modifica la estructura del notebook. create usa PUT /api/contents para crear notebooks nuevos sin necesidad de websocket. batch abre una unica conexion WebSocket para insertar N celdas en una sola operacion.

tags:
  ["jupyter","notebook","websocket","cell","write","append","insert","edit","delete","create","batch","nbmodel","rest"]

example:
  ```python
  from notebook.jupyter_write import (
      jupyter_append_code,
      jupyter_append_markdown,
      jupyter_insert_cell,
      jupyter_edit_cell,
      jupyter_delete_cell,
      jupyter_create_notebook,
      jupyter_batch_write,
  )

  # Crear notebook nuevo
  result = jupyter_create_notebook(
      notebook_path="notebooks/01_analisis.ipynb",
      kernel_name="python3",
      server_url="http://localhost:8888",
  )
  # {"action": "create", "notebook": "notebooks/01_analisis.ipynb", "created": true}

  # Si ya existe, lanza FileExistsError. Usar force=True para sobreescribir:
  result = jupyter_create_notebook("notebooks/01.ipynb", force=True)

  # Anadir multiples celdas de golpe (una sola conexion WebSocket)
  cells = [
      {"type": "markdown", "source": "## Analisis inicial"},
      {"type": "code", "source": "import pandas as pd"},
      {"type": "code", "source": "df = pd.read_csv('data.csv')\ndf.head()"},
  ]
  result = jupyter_batch_write(
      notebook_path="notebooks/01_analisis.ipynb",
      cells=cells,
      server_url="http://localhost:8888",
  )
  # {"action": "batch", "cells_added": 3, "notebook": "notebooks/01_analisis.ipynb"}

  # Anadir celda de codigo al final
  result = jupyter_append_code(
      notebook_path="notebooks/01_analisis.ipynb",
      source="import pandas as pd\ndf = pd.read_csv('data.csv')",
  )
  # {"action": "append_code", "cell_index": 5, "notebook": "notebooks/01_analisis.ipynb"}

  # Anadir celda markdown
  result = jupyter_append_markdown(
      notebook_path="notebooks/01_analisis.ipynb",
      source="## Resultados\n\nAnalisis de los datos obtenidos.",
  )
  # {"action": "append_markdown", "cell_index": 6, "notebook": "..."}

  # Insertar celda en posicion 2
  result = jupyter_insert_cell(
      notebook_path="notebooks/01_analisis.ipynb",
      cell_index=2,
      source="# celda insertada",
      cell_type="code",
  )
  # {"action": "insert", "cell_index": 2, "cell_type": "code", "notebook": "..."}

  # Editar celda existente (indice 0)
  result = jupyter_edit_cell(
      notebook_path="notebooks/01_analisis.ipynb",
      cell_index=0,
      source="# Titulo actualizado",
  )
  # {"action": "edit", "cell_index": 0, "notebook": "..."}

  # Eliminar celda
  result = jupyter_delete_cell(
      notebook_path="notebooks/01_analisis.ipynb",
      cell_index=3,
  )
  # {"action": "delete", "cell_index": 3, "notebook": "..."}
  ```

notes:
  - Todas las funciones son sincronas publicamente. Internamente usan `asyncio.run()` sobre corutinas async que se comunican via WebSocket con `NbModelClient`.
  - `create` es la excepcion: usa urllib (PUT /api/contents) sin WebSocket. Crea un nbformat 4 con celdas vacias. Lanza `FileExistsError` si el notebook ya existe y `force=False`.
  - `batch` es mucho mas eficiente que N llamadas a `append-code`/`append-markdown`: abre una sola conexion WebSocket y hace un unico `asyncio.sleep(2)` de sincronizacion al final.
  - El `notebook_path` es relativo al servidor Jupyter (no al filesystem local).
  - Si el servidor no esta corriendo o el token es incorrecto, lanza excepcion de conexion de `jupyter_nbmodel_client`.
  - NO ejecuta celdas — solo modifica la estructura. Para ejecutar, usar `jupyter_exec`.
  - `server_url` y `token` tienen defaults convenientes para desarrollo local (`http://localhost:8888`, token vacio).
  - El campo `cell_index` en el resultado refleja la posicion final de la celda en el notebook.
  - `append_code`, `append_markdown` y `batch` crean el notebook automaticamente si no existe (auto-create via REST). No es necesario llamar a `create` previamente.
  - Patron tipico: `batch` para poblar las celdas iniciales (crea el notebook si no existe), o `create` + `batch` si se necesita control explicito.

documentation:
  ## Funciones expuestas

  | Funcion | Descripcion |
  |---------|-------------|
  | `jupyter_append_code(notebook_path, source, server_url, token)` | Anade celda de codigo al final |
  | `jupyter_append_markdown(notebook_path, source, server_url, token)` | Anade celda markdown al final |
  | `jupyter_insert_cell(notebook_path, cell_index, source, cell_type, server_url, token)` | Inserta celda en posicion especifica |
  | `jupyter_edit_cell(notebook_path, cell_index, source, server_url, token)` | Sobrescribe contenido de celda existente |
  | `jupyter_delete_cell(notebook_path, cell_index, server_url, token)` | Elimina una celda |
  | `jupyter_create_notebook(notebook_path, kernel_name, server_url, token, force)` | Crea un notebook vacio nbformat 4 via API REST |
  | `jupyter_batch_write(notebook_path, cells, server_url, token)` | Anade N celdas en una sola conexion WebSocket |

  ## CLI

  ```bash
  # Crear notebook nuevo
  python -m notebook.jupyter_write create notebooks/01.ipynb
  python -m notebook.jupyter_write create notebooks/01.ipynb --kernel python3
  python -m notebook.jupyter_write create notebooks/01.ipynb --force

  # Anadir multiples celdas desde archivo JSON
  python -m notebook.jupyter_write batch notebooks/01.ipynb --from cells.json

  # O via stdin
  cat cells.json | python -m notebook.jupyter_write batch notebooks/01.ipynb --from -
  echo '[{"type":"code","source":"import pandas"}]' | python -m notebook.jupyter_write batch notebooks/01.ipynb

  # Formato JSON de entrada para batch:
  # [{"type": "markdown", "source": "# Titulo"}, {"type": "code", "source": "import pandas"}]

  # Anadir celda de codigo
  python -m notebook.jupyter_write append-code notebooks/01.ipynb "print('hola')" --server http://localhost:8888 --token mi-token

  # Anadir celda markdown
  python -m notebook.jupyter_write append-markdown notebooks/01.ipynb "## Titulo"

  # Insertar en posicion 2
  python -m notebook.jupyter_write insert notebooks/01.ipynb 2 "x = 42" --type code

  # Editar celda 0
  python -m notebook.jupyter_write edit notebooks/01.ipynb 0 "# Nuevo titulo"

  # Eliminar celda 3
  python -m notebook.jupyter_write delete notebooks/01.ipynb 3
  ```

code:
  """Operaciones de escritura sobre celdas de un notebook Jupyter via colaboracion en tiempo real.

  NO ejecuta celdas — solo modifica la estructura del notebook (append, insert, edit, delete, create, batch).
  Usa jupyter_nbmodel_client para comunicarse con el servidor Jupyter via WebSocket.
  Para crear notebooks usa la API REST de Jupyter (urllib PUT /api/contents).
  """

  import asyncio
  import json
  import argparse
  import sys
  from urllib.error import URLError, HTTPError
  from urllib.request import Request, urlopen

  from jupyter_nbmodel_client import NbModelClient, get_jupyter_notebook_websocket_url


  def _resolve_collab_username(server_url: str, token: str) -> str:
      """Resolve the display name of the active user in Jupyter collaboration."""
      headers = {"Accept": "application/json"}
      if token:
          headers["Authorization"] = f"token {token}"
      try:
          req = Request(f"{server_url}/api/me", headers=headers)
          with urlopen(req, timeout=5) as resp:
              me = json.loads(resp.read())
          identity = me.get("identity", {})
          return identity.get("display_name", "") or identity.get("username", "") or identity.get("name", "Anonymous")
      except (URLError, OSError, json.JSONDecodeError):
          return "Anonymous"


  # ---------------------------------------------------------------------------
  # Helpers internos
  # ---------------------------------------------------------------------------


  def _notebook_exists(notebook_path: str, server_url: str, token: str) -> bool:
      """Comprueba si un notebook existe en el servidor Jupyter via GET /api/contents.

      Usa GET con ?content=0 (metadata only). HEAD no es soportado universalmente
      (Jupyter 4.x devuelve 405 Method Not Allowed en /api/contents/{path}).
      """
      headers = {"Accept": "application/json"}
      if token:
          headers["Authorization"] = f"token {token}"
      check_url = f"{server_url}/api/contents/{notebook_path}?content=0"
      req = Request(check_url, headers=headers, method="GET")
      try:
          with urlopen(req, timeout=5):
              return True
      except HTTPError as e:
          if e.code == 404:
              return False
          raise


  def _auto_create_notebook(notebook_path: str, server_url: str, token: str) -> bool:
      """Crea el notebook si no existe. Retorna True si fue creado."""
      if not _notebook_exists(notebook_path, server_url, token):
          jupyter_create_notebook(notebook_path, server_url=server_url, token=token)
          return True
      return False


  # ---------------------------------------------------------------------------
  # Helpers internos async
  # ---------------------------------------------------------------------------


  async def _append_cell(
      notebook_path: str,
      source: str,
      cell_type: str,
      server_url: str,
      token: str,
  ) -> dict:
      _auto_create_notebook(notebook_path, server_url, token)
      ws_url = get_jupyter_notebook_websocket_url(
          server_url=server_url,
          token=token,
          path=notebook_path,
      )
      username = _resolve_collab_username(server_url, token)
      async with NbModelClient(ws_url, username=username) as nb:
          if cell_type == "markdown":
              nb.add_markdown_cell(source)
          else:
              nb.add_code_cell(source)
          cell_index = len(nb) - 1
          await asyncio.sleep(2)
      return {
          "action": f"append_{cell_type}",
          "cell_index": cell_index,
          "notebook": notebook_path,
      }


  async def _insert_cell(
      notebook_path: str,
      cell_index: int,
      source: str,
      cell_type: str,
      server_url: str,
      token: str,
  ) -> dict:
      ws_url = get_jupyter_notebook_websocket_url(
          server_url=server_url,
          token=token,
          path=notebook_path,
      )
      username = _resolve_collab_username(server_url, token)
      async with NbModelClient(ws_url, username=username) as nb:
          nb.insert_cell(cell_index, cell_type=cell_type, source=source)
          await asyncio.sleep(2)
      return {
          "action": "insert",
          "cell_index": cell_index,
          "cell_type": cell_type,
          "notebook": notebook_path,
      }


  async def _edit_cell(
      notebook_path: str,
      cell_index: int,
      source: str,
      server_url: str,
      token: str,
  ) -> dict:
      ws_url = get_jupyter_notebook_websocket_url(
          server_url=server_url,
          token=token,
          path=notebook_path,
      )
      username = _resolve_collab_username(server_url, token)
      async with NbModelClient(ws_url, username=username) as nb:
          nb.set_cell_source(cell_index, source)
          await asyncio.sleep(2)
      return {
          "action": "edit",
          "cell_index": cell_index,
          "notebook": notebook_path,
      }


  async def _delete_cell(
      notebook_path: str,
      cell_index: int,
      server_url: str,
      token: str,
  ) -> dict:
      ws_url = get_jupyter_notebook_websocket_url(
          server_url=server_url,
          token=token,
          path=notebook_path,
      )
      username = _resolve_collab_username(server_url, token)
      async with NbModelClient(ws_url, username=username) as nb:
          nb.delete_cell(cell_index)
          await asyncio.sleep(2)
      return {
          "action": "delete",
          "cell_index": cell_index,
          "notebook": notebook_path,
      }


  async def _batch_write(
      notebook_path: str,
      cells: list,
      server_url: str,
      token: str,
  ) -> dict:
      """Anade multiples celdas en una sola conexion WebSocket."""
      _auto_create_notebook(notebook_path, server_url, token)
      ws_url = get_jupyter_notebook_websocket_url(
          server_url=server_url,
          token=token,
          path=notebook_path,
      )
      username = _resolve_collab_username(server_url, token)
      async with NbModelClient(ws_url, username=username) as nb:
          for cell in cells:
              cell_type = cell.get("type", "code")
              source = cell.get("source", "")
              if cell_type == "markdown":
                  nb.add_markdown_cell(source)
              else:
                  nb.add_code_cell(source)
          await asyncio.sleep(2)
      return {
          "action": "batch",
          "cells_added": len(cells),
          "notebook": notebook_path,
      }


  # ---------------------------------------------------------------------------
  # API publica sincrona
  # ---------------------------------------------------------------------------


  def jupyter_append_code(
      notebook_path: str,
      source: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict:
      """Anade una celda de codigo al final del notebook.

      Args:
          notebook_path: Ruta relativa al notebook dentro del servidor Jupyter.
          source: Codigo fuente de la celda.
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con action, cell_index y notebook.
      """
      return asyncio.run(_append_cell(notebook_path, source, "code", server_url, token))


  def jupyter_append_markdown(
      notebook_path: str,
      source: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict:
      """Anade una celda markdown al final del notebook.

      Args:
          notebook_path: Ruta relativa al notebook dentro del servidor Jupyter.
          source: Contenido markdown de la celda.
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con action, cell_index y notebook.
      """
      return asyncio.run(
          _append_cell(notebook_path, source, "markdown", server_url, token)
      )


  def jupyter_insert_cell(
      notebook_path: str,
      cell_index: int,
      source: str,
      cell_type: str = "code",
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict:
      """Inserta una celda en una posicion especifica del notebook.

      Args:
          notebook_path: Ruta relativa al notebook dentro del servidor Jupyter.
          cell_index: Indice donde insertar (0 = primer posicion).
          source: Contenido de la celda.
          cell_type: Tipo de celda: "code" o "markdown".
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con action, cell_index, cell_type y notebook.
      """
      return asyncio.run(
          _insert_cell(notebook_path, cell_index, source, cell_type, server_url, token)
      )


  def jupyter_edit_cell(
      notebook_path: str,
      cell_index: int,
      source: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict:
      """Sobrescribe el contenido de una celda existente.

      Args:
          notebook_path: Ruta relativa al notebook dentro del servidor Jupyter.
          cell_index: Indice de la celda a editar (0-based).
          source: Nuevo contenido de la celda.
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con action, cell_index y notebook.
      """
      return asyncio.run(
          _edit_cell(notebook_path, cell_index, source, server_url, token)
      )


  def jupyter_delete_cell(
      notebook_path: str,
      cell_index: int,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict:
      """Elimina una celda del notebook.

      Args:
          notebook_path: Ruta relativa al notebook dentro del servidor Jupyter.
          cell_index: Indice de la celda a eliminar (0-based).
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con action, cell_index y notebook.
      """
      return asyncio.run(
          _delete_cell(notebook_path, cell_index, server_url, token)
      )


  def jupyter_create_notebook(
      notebook_path: str,
      kernel_name: str = "python3",
      server_url: str = "http://localhost:8888",
      token: str = "",
      force: bool = False,
  ) -> dict:
      """Crea un notebook vacio (nbformat 4) via la API REST de Jupyter.

      Usa PUT /api/contents/{path} con type: notebook. Si el notebook ya existe
      y force=False, lanza un error en vez de sobreescribirlo.

      Args:
          notebook_path: Ruta relativa al notebook dentro del servidor Jupyter.
          kernel_name: Nombre del kernel a asociar (default: python3).
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion del servidor Jupyter.
          force: Si True, sobreescribe el notebook si ya existe.

      Returns:
          dict con action, notebook y created.
      """
      headers = {
          "Content-Type": "application/json",
          "Accept": "application/json",
      }
      if token:
          headers["Authorization"] = f"token {token}"

      # Verificar si ya existe (GET con content=0; HEAD no esta soportado en Jupyter 4.x)
      check_url = f"{server_url}/api/contents/{notebook_path}"
      check_req = Request(f"{check_url}?content=0", headers=headers, method="GET")
      already_exists = False
      try:
          with urlopen(check_req, timeout=5):
              already_exists = True
      except HTTPError as e:
          if e.code != 404:
              raise
      except URLError:
          raise

      if already_exists and not force:
          raise FileExistsError(
              f"Notebook ya existe: {notebook_path}. Usa force=True para sobreescribir."
          )

      kernel_display = {
          "python3": "Python 3 (ipykernel)",
          "python": "Python 3",
      }.get(kernel_name, kernel_name)

      notebook_content = {
          "nbformat": 4,
          "nbformat_minor": 5,
          "metadata": {
              "kernelspec": {
                  "name": kernel_name,
                  "display_name": kernel_display,
                  "language": "python",
              },
              "language_info": {
                  "name": "python",
              },
          },
          "cells": [],
      }

      body = json.dumps({
          "type": "notebook",
          "content": notebook_content,
      }).encode("utf-8")

      put_req = Request(check_url, data=body, headers=headers, method="PUT")
      with urlopen(put_req, timeout=10) as resp:
          resp.read()

      return {
          "action": "create",
          "notebook": notebook_path,
          "created": True,
      }


  def jupyter_batch_write(
      notebook_path: str,
      cells: list,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict:
      """Anade multiples celdas al notebook en una sola conexion WebSocket.

      Mucho mas eficiente que llamar append-code/append-markdown N veces porque
      abre una unica conexion WebSocket y hace un solo sleep de sincronizacion
      al final de todas las inserciones.

      Args:
          notebook_path: Ruta relativa al notebook dentro del servidor Jupyter.
          cells: Lista de dicts con claves "type" ("code"|"markdown") y "source" (str).
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con action, cells_added y notebook.
      """
      return asyncio.run(_batch_write(notebook_path, cells, server_url, token))


  # ---------------------------------------------------------------------------
  # CLI
  # ---------------------------------------------------------------------------


  def _build_parser() -> argparse.ArgumentParser:
      parser = argparse.ArgumentParser(
          prog="jupyter_write",
          description="Operaciones de escritura sobre celdas de un notebook Jupyter.",
      )
      sub = parser.add_subparsers(dest="command", required=True)

      # Argumentos comunes
      def add_common(p: argparse.ArgumentParser) -> None:
          p.add_argument("--server", default="http://localhost:8888", help="URL del servidor Jupyter")
          p.add_argument("--token", default="", help="Token de autenticacion")

      # append-code
      p_ac = sub.add_parser("append-code", help="Anade celda de codigo al final")
      p_ac.add_argument("notebook", help="Ruta del notebook")
      p_ac.add_argument("source", help="Codigo fuente")
      add_common(p_ac)

      # append-markdown
      p_am = sub.add_parser("append-markdown", help="Anade celda markdown al final")
      p_am.add_argument("notebook", help="Ruta del notebook")
      p_am.add_argument("source", help="Contenido markdown")
      add_common(p_am)

      # insert
      p_ins = sub.add_parser("insert", help="Inserta celda en posicion especifica")
      p_ins.add_argument("notebook", help="Ruta del notebook")
      p_ins.add_argument("index", type=int, help="Indice de insercion (0-based)")
      p_ins.add_argument("source", help="Contenido de la celda")
      p_ins.add_argument("--type", dest="cell_type", choices=["code", "markdown"], default="code")
      add_common(p_ins)

      # edit
      p_ed = sub.add_parser("edit", help="Sobrescribe el contenido de una celda")
      p_ed.add_argument("notebook", help="Ruta del notebook")
      p_ed.add_argument("index", type=int, help="Indice de la celda (0-based)")
      p_ed.add_argument("source", help="Nuevo contenido")
      add_common(p_ed)

      # delete
      p_del = sub.add_parser("delete", help="Elimina una celda")
      p_del.add_argument("notebook", help="Ruta del notebook")
      p_del.add_argument("index", type=int, help="Indice de la celda (0-based)")
      add_common(p_del)

      # create
      p_cr = sub.add_parser("create", help="Crea un notebook vacio via API REST")
      p_cr.add_argument("notebook", help="Ruta del notebook a crear")
      p_cr.add_argument("--kernel", default="python3", help="Nombre del kernel (default: python3)")
      p_cr.add_argument("--force", action="store_true", help="Sobreescribir si ya existe")
      add_common(p_cr)

      # batch
      p_bt = sub.add_parser("batch", help="Anade multiples celdas en una sola conexion WebSocket")
      p_bt.add_argument("notebook", help="Ruta del notebook")
      p_bt.add_argument(
          "--from",
          dest="cells_source",
          default="-",
          metavar="FILE",
          help="Archivo JSON con las celdas, o '-' para leer de stdin (default: -)",
      )
      add_common(p_bt)

      return parser


  def main() -> None:
      parser = _build_parser()
      args = parser.parse_args()

      if args.command == "append-code":
          result = jupyter_append_code(args.notebook, args.source, args.server, args.token)
      elif args.command == "append-markdown":
          result = jupyter_append_markdown(args.notebook, args.source, args.server, args.token)
      elif args.command == "insert":
          result = jupyter_insert_cell(
              args.notebook, args.index, args.source, args.cell_type, args.server, args.token
          )
      elif args.command == "edit":
          result = jupyter_edit_cell(args.notebook, args.index, args.source, args.server, args.token)
      elif args.command == "delete":
          result = jupyter_delete_cell(args.notebook, args.index, args.server, args.token)
      elif args.command == "create":
          result = jupyter_create_notebook(
              args.notebook,
              kernel_name=args.kernel,
              server_url=args.server,
              token=args.token,
              force=args.force,
          )
      elif args.command == "batch":
          if args.cells_source == "-":
              raw = sys.stdin.read()
          else:
              with open(args.cells_source, "r", encoding="utf-8") as f:
                  raw = f.read()
          cells = json.loads(raw)
          result = jupyter_batch_write(args.notebook, cells, args.server, args.token)
      else:
          parser.print_help()
          return

      print(json.dumps(result, indent=2))


  if __name__ == "__main__":
      main()

params_schema:
  {"output":"Múltiples funciones para escribir en notebooks: append_code/markdown, insert, edit, delete, create, batch (retornan dicts con action y posición)"}


id: extraction_pipeline_py_pipelines
name: extraction_pipeline
kind: pipeline
lang: py
domain: pipelines
version: 1.0.0
purity: impure
tags: ["pipeline","extraction","entities","relations","llm","nlp","fuzzygraph","datascience"]
returns: ["extraction_result_py_datascience"]
error_type: error_go_core
imports: ["time","warnings","typing.Callable"]
test_file_path: python/functions/pipelines/extraction_pipeline_test.py
file_path: python/functions/pipelines/extraction_pipeline.py
created_at: 2026-04-05T14:45:08Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: acfba2dd2501c2112856b4cd572ef697f42a6afc68fef80b53835add4ad8047c
signature:
  def extraction_pipeline(file_path: str, entity_presets: list[dict], relation_types: list[str], llm_chat_json: Callable[[list[dict]], dict], chunk_size: int = 500, chunk_overlap: int = 50, confidence_threshold: float = 0.5, dedup_threshold: float = 0.85, on_progress: Callable[[str, float], None] | None = None) -> ExtractionResult

description:
  Pipeline completa de extraccion de entidades y relaciones desde un documento. Orquesta extract_text_from_file -> preprocess_text -> split_text_into_chunks -> extract_entities_llm por chunk -> deduplicate_entities -> extract_relations_llm por chunk -> deduplicate_relations.

uses_functions:
  ["extract_text_from_file_py_core","preprocess_text_py_core","split_text_into_chunks_py_core","build_entity_schema_prompt_py_datascience","build_relation_schema_prompt_py_datascience","extract_entities_llm_py_datascience","extract_relations_llm_py_datascience","deduplicate_entities_py_datascience","deduplicate_relations_py_datascience"]

uses_types:
  ["entity_candidate_py_datascience","extraction_result_py_datascience","extraction_stats_py_datascience","relation_candidate_py_datascience"]

example:
  ```python
  from python.functions.pipelines.extraction_pipeline import extraction_pipeline

  entity_presets = [
      {
          "type_ref": "osint_person_go_cybersecurity",
          "label": "Person",
          "metadata_fields": ["full_name", "alias", "nationality"],
      },
      {
          "type_ref": "osint_domain_go_cybersecurity",
          "label": "Domain",
          "metadata_fields": ["fqdn", "registrar"],
      },
  ]

  relation_types = ["operates", "owns", "funds", "communicates_with", "related_to"]

  # Inyectar un cliente LLM real
  def llm_chat_json(messages):
      # llamada al proveedor LLM elegido
      ...

  result = extraction_pipeline(
      file_path="report.pdf",
      entity_presets=entity_presets,
      relation_types=relation_types,
      llm_chat_json=llm_chat_json,
      chunk_size=500,
      chunk_overlap=50,
      confidence_threshold=0.5,
      dedup_threshold=0.85,
      on_progress=lambda msg, pct: print(f"[{pct:.0%}] {msg}"),
  )

  print(f"Entities: {len(result.entities)}, Relations: {len(result.relations)}")
  print(f"Stats: {result.stats}")

  # Integrar con fuzzygraph / operations.db
  for entity in result.entities:
      db.add_entity(
          name=entity.name,
          type_ref=entity.type_ref,
          metadata=entity.attributes,
      )

  for relation in result.relations:
      db.add_relation(
          name=relation.relation_type,
          from_entity=relation.from_id,
          to_entity=relation.to_id,
      )
  ```

tests:
  ["documento con entidades y relaciones retorna ExtractionResult completo","documento vacio retorna ExtractionResult con listas vacias","documento sin entidades detectables retorna listas vacias","archivo no encontrado lanza FileNotFoundError","entity presets vacio lanza ValueError","progress callback se invoca durante la ejecucion","stats se rellenan correctamente con conteos y tiempo"]

notes:
  - El parametro `llm_chat_json` inyecta el cliente LLM, sin acoplamiento a ningun proveedor (OpenAI, Anthropic, Ollama, etc.)
  - El progress callback cubre: 0-40% extraccion de entidades, 40-80% extraccion de relaciones, 80-100% deduplicacion
  - Si el archivo no existe lanza `FileNotFoundError` antes de cualquier llamada al LLM
  - Si `entity_presets` esta vacio lanza `ValueError`
  - Errores en chunks individuales se capturan con warnings y continuan (robustez)
  - Los `entity_id_map` de `deduplicate_entities` conectan nombres originales del texto con IDs UUID finales para `deduplicate_relations`
  - La retorna `ExtractionResult` esta lista para insertar en `operations.db` via `fn ops entity add` / `fn ops relation add`

documentation:
  ## Algoritmo

  1. **Extract:** `extract_text_from_file(file_path)` — texto crudo desde PDF, TXT, Markdown
  2. **Preprocess:** `preprocess_text(text)` — normaliza espacios, caracteres especiales
  3. **Split:** `split_text_into_chunks(text, chunk_size, chunk_overlap)` — divide en ventanas solapadas
  4. **Extract entities per chunk (0-40%):** Para cada chunk llama `extract_entities_llm` con el schema de presets. Anota `source_chunk_index` en cada candidato
  5. **Filter:** filtra por `confidence >= confidence_threshold`
  6. **Deduplicate entities (40%):** `deduplicate_entities` con fuzzy matching, produce `entity_id_map`
  7. **Extract relations per chunk (40-80%):** Para cada chunk obtiene las entidades de ese chunk y llama `extract_relations_llm`
  8. **Deduplicate relations (80-100%):** `deduplicate_relations` resuelve nombres a IDs y colapsa duplicados
  9. **Return:** `ExtractionResult` con entidades, relaciones y stats del proceso

code:
  """Pipeline de extraccion de entidades y relaciones desde un documento."""

  from __future__ import annotations

  import sys
  import os
  import time
  import warnings
  from typing import Callable

  # Soporte para ejecucion desde la raiz del registry o desde el directorio del archivo
  _ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
  if _ROOT not in sys.path:
      sys.path.insert(0, _ROOT)

  from python.functions.core.extract_text_from_file import extract_text_from_file
  from python.functions.core.core import preprocess_text
  from python.functions.core.split_text_into_chunks import split_text_into_chunks
  from python.functions.datascience.build_entity_schema_prompt import build_entity_schema_prompt
  from python.functions.datascience.build_relation_schema_prompt import build_relation_schema_prompt
  from python.functions.datascience.extract_entities_llm import extract_entities_llm
  from python.functions.datascience.extract_relations_llm import extract_relations_llm
  from python.functions.datascience.deduplicate_entities import deduplicate_entities
  from python.functions.datascience.deduplicate_relations import deduplicate_relations
  from python.types.datascience.entity_candidate import EntityCandidate
  from python.types.datascience.extraction_result import ExtractionResult
  from python.types.datascience.extraction_stats import ExtractionStats


  def extraction_pipeline(
      file_path: str,
      entity_presets: list[dict],
      relation_types: list[str],
      llm_chat_json: Callable[[list[dict]], dict],
      chunk_size: int = 500,
      chunk_overlap: int = 50,
      confidence_threshold: float = 0.5,
      dedup_threshold: float = 0.85,
      on_progress: Callable[[str, float], None] | None = None,
  ) -> ExtractionResult:
      """Pipeline completa de extraccion de entidades y relaciones desde un documento.

      Orquesta extract_text_from_file -> preprocess_text -> split_text_into_chunks
      -> extract_entities_llm por chunk -> deduplicate_entities ->
      extract_relations_llm por chunk -> deduplicate_relations.

      Args:
          file_path: ruta al archivo a procesar (PDF, Markdown, TXT).
          entity_presets: lista de dicts con type_ref, label y metadata_fields.
              Ejemplo: [{"type_ref": "osint_person_go_cybersecurity",
                          "label": "Person",
                          "metadata_fields": ["full_name", "nationality"]}]
          relation_types: tipos de relacion permitidos para extraccion.
              Ejemplo: ["funds", "employs", "communicates_with", "owns"]
          llm_chat_json: funcion inyectada que recibe messages OpenAI y retorna dict
              con la respuesta JSON ya parseada. Sin acoplamiento a ningun proveedor.
          chunk_size: numero de caracteres por chunk (default 500).
          chunk_overlap: overlap entre chunks consecutivos (default 50).
          confidence_threshold: umbral minimo de confidence para aceptar entidades
              candidatas antes de deduplicar (default 0.5).
          dedup_threshold: score minimo de similitud para mergear entidades (default 0.85).
          on_progress: callback opcional de progreso (message: str, pct: float 0-1).
              0-40%: extraccion de entidades, 40-80%: extraccion de relaciones,
              80-100%: deduplicacion.

      Returns:
          ExtractionResult con entidades y relaciones deduplicadas y stats del proceso.

      Raises:
          FileNotFoundError: si file_path no existe.
          ValueError: si entity_presets esta vacio.
      """
      if not entity_presets:
          raise ValueError("entity_presets no puede estar vacio")

      if not os.path.exists(file_path):
          raise FileNotFoundError(f"Archivo no encontrado: {file_path}")

      def _progress(msg: str, pct: float) -> None:
          if on_progress is not None:
              try:
                  on_progress(msg, pct)
              except Exception:
                  pass

      start_time = time.monotonic()
      stats = ExtractionStats()

      # ── Paso 1: Extraer texto ──────────────────────────────────────────────────
      _progress("Extracting text from file...", 0.0)
      try:
          raw_text = extract_text_from_file(file_path)
      except Exception as exc:
          warnings.warn(f"extraction_pipeline: error al extraer texto: {exc}")
          raw_text = ""

      # ── Paso 2: Preprocesar ────────────────────────────────────────────────────
      clean_text = preprocess_text(raw_text)
      stats.total_chars = len(clean_text)

      # ── Paso 3: Dividir en chunks ──────────────────────────────────────────────
      chunks = split_text_into_chunks(clean_text, chunk_size=chunk_size, overlap=chunk_overlap)
      n = len(chunks)
      stats.total_chunks = n

      if n == 0:
          stats.processing_time_seconds = time.monotonic() - start_time
          return ExtractionResult(entities=[], relations=[], stats=stats)

      # ── Paso 4: Extraer entidades por chunk ────────────────────────────────────
      all_raw_entities: list[EntityCandidate] = []

      for i, chunk in enumerate(chunks):
          _progress(f"Extracting entities from chunk {i + 1}/{n}", (i / n) * 0.4)
          try:
              candidates = extract_entities_llm(
                  text=chunk,
                  entity_schema=entity_presets,
                  llm_chat_json=llm_chat_json,
              )
          except Exception as exc:
              warnings.warn(
                  f"extraction_pipeline: error en extract_entities_llm chunk {i}: {exc}"
              )
              candidates = []

          for candidate in candidates:
              # Anotar el chunk de origen
              if i not in candidate.source_chunk_indices:
                  candidate.source_chunk_indices.append(i)
              all_raw_entities.append(candidate)

      # ── Paso 5: Filtrar por confidence ─────────────────────────────────────────
      filtered_entities = [
          e for e in all_raw_entities if e.confidence >= confidence_threshold
      ]
      stats.raw_entities_count = len(filtered_entities)

      # Actualizar stats de tipos
      for ent in filtered_entities:
          stats.entity_types_found[ent.type_ref] = (
              stats.entity_types_found.get(ent.type_ref, 0) + 1
          )

      # ── Paso 6: Deduplicar entidades ───────────────────────────────────────────
      _progress("Deduplicating entities...", 0.4)
      dedup_result = deduplicate_entities(filtered_entities, name_threshold=dedup_threshold)

      stats.final_entities_count = dedup_result.total_after
      stats.entities_merged = dedup_result.total_before - dedup_result.total_after

      final_entities = dedup_result.entities
      entity_id_map = dedup_result.name_to_id  # nombre_original -> entity_id

      # ── Paso 7: Extraer relaciones por chunk ───────────────────────────────────
      all_raw_relations = []

      for i, chunk in enumerate(chunks):
          _progress(f"Extracting relations...", 0.4 + (i / n) * 0.4)

          # Obtener entidades relevantes de este chunk
          chunk_entities = [
              e for e in final_entities if i in e.source_chunk_indices
          ]
          # Si no hay entidades en este chunk especifico, usar todas
          if not chunk_entities:
              chunk_entities = final_entities

          if len(chunk_entities) < 2:
              continue

          try:
              chunk_relations = extract_relations_llm(
                  text=chunk,
                  entities=chunk_entities,
                  relation_types=relation_types,
                  llm_chat_json=llm_chat_json,
              )
          except Exception as exc:
              warnings.warn(
                  f"extraction_pipeline: error en extract_relations_llm chunk {i}: {exc}"
              )
              chunk_relations = []

          for rel in chunk_relations:
              rel.source_chunk_index = i
          all_raw_relations.extend(chunk_relations)

      stats.raw_relations_count = len(all_raw_relations)

      # Actualizar stats de tipos de relacion
      for rel in all_raw_relations:
          stats.relation_types_found[rel.relation_type] = (
              stats.relation_types_found.get(rel.relation_type, 0) + 1
          )

      # ── Paso 8: Deduplicar relaciones ──────────────────────────────────────────
      _progress("Deduplicating relations...", 0.8)
      final_relations = deduplicate_relations(all_raw_relations, entity_id_map)

      stats.final_relations_count = len(final_relations)
      stats.relations_merged = stats.raw_relations_count - len(final_relations)
      stats.processing_time_seconds = time.monotonic() - start_time

      _progress("Done", 1.0)

      return ExtractionResult(
          entities=final_entities,
          relations=final_relations,
          stats=stats,
      )

params_schema:
  {"params":[{"name":"file_path","desc":"Ruta del documento (PDF, TXT, Markdown) a procesar"},{"name":"entity_presets","desc":"Configuración de tipos de entidades a extraer con sus metadatos"},{"name":"relation_types","desc":"Tipos de relaciones a extraer (ej: 'owns', 'operates', 'communicates_with')"},{"name":"llm_chat_json","desc":"Función inyectada para llamadas al LLM (sin acoplamiento a proveedor)"},{"name":"chunk_size","desc":"Tamaño de chunks para procesamiento (default 500)"},{"name":"chunk_overlap","desc":"Solapamiento entre chunks (default 50)"},{"name":"confidence_threshold","desc":"Confianza mínima para incluir entidades (default 0.5)"},{"name":"dedup_threshold","desc":"Umbral fuzzy para deduplicación (default 0.85)"},{"name":"on_progress","desc":"Callback opcional para progreso (msg, percentage)"}],"output":"ExtractionResult con entidades, relaciones y estadísticas del proceso de extracción"}


id: metabase_add_ops_db_py_pipelines
name: metabase_add_ops_db
kind: pipeline
lang: py
domain: pipelines
version: 1.0.0
purity: impure
signature: metabase_add_ops_db(app_name: str) -> None
tags: ["metabase","operations","sqlite","docker","pipeline","infra","launcher"]
uses_functions: ["metabase_auth_py_infra","metabase_list_databases_py_infra","metabase_add_database_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/pipelines/metabase_add_ops_db.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 2f144a4bfd598d6284da2c36430b99cb651ad8f1387a650f8f0b0009880a6c78
description:
  Registra la operations.db de una app en Metabase como database SQLite. Verifica duplicados y muestra el mount necesario para el contenedor Docker.

example:
  ```bash
  # Registrar una app
  python python/functions/pipelines/metabase_add_ops_db.py docker_tui

  # Listar estado de todas las apps
  python python/functions/pipelines/metabase_add_ops_db.py --list
  ```

documentation:
  ## Flujo

  1. `metabase_auth` - autentica contra Metabase
  2. `metabase_list_databases` - verifica si la database ya existe
  3. `metabase_add_database` - registra la operations.db como SQLite

  ## Requisitos

  El contenedor Metabase debe tener montado el directorio de la app como volumen RW:

  ```
  -v /home/lucas/fn_registry/apps/<app_name>:/data/ops-<app-name>
  ```

  Ademas, el directorio debe tener permisos para el usuario metabase (UID 2000):

  ```bash
  docker exec -u root <container> chown -R metabase:metabase /data/ops-<app-name>/
  ```

  ## Convencion de nombres

  - Database en Metabase: `ops-<app-name>` (guiones en vez de underscores)
  - Path en contenedor: `/data/ops-<app-name>/operations.db`

code:
  """Pipeline: registra la operations.db de una app en Metabase.

  Compone: metabase_auth + metabase_list_databases + metabase_add_database.

  Requiere que el contenedor Metabase tenga montado el directorio de la app
  como volumen RW para que SQLite pueda crear journal files.

  Uso:
      python metabase_add_ops_db.py <app_name>
      python metabase_add_ops_db.py docker_tui
      python metabase_add_ops_db.py --list
  """

  import argparse
  import os
  import sys

  sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

  from metabase.client import metabase_auth
  from metabase import metabase_list_databases, metabase_add_database

  METABASE_URL = os.environ.get("METABASE_URL", "http://localhost:3000")
  EMAIL = os.environ.get("METABASE_ADMIN_EMAIL", "admin@fnregistry.local")
  PASSWORD = os.environ.get("METABASE_ADMIN_PASSWORD", "FnRegistry2024!")

  CONTAINER_DATA_PREFIX = "/data/ops-"


  def find_apps(registry_root: str) -> list[str]:
      """Lista apps que tienen operations.db."""
      apps_dir = os.path.join(registry_root, "apps")
      if not os.path.isdir(apps_dir):
          return []
      return sorted(
          d
          for d in os.listdir(apps_dir)
          if os.path.isfile(os.path.join(apps_dir, d, "operations.db"))
      )


  def db_name_for_app(app_name: str) -> str:
      return f"ops-{app_name.replace('_', '-')}"


  def container_path_for_app(app_name: str) -> str:
      return f"{CONTAINER_DATA_PREFIX}{app_name.replace('_', '-')}/operations.db"


  def main():
      parser = argparse.ArgumentParser(description="Registra operations.db de una app en Metabase")
      parser.add_argument("app_name", nargs="?", help="Nombre de la app (directorio en apps/)")
      parser.add_argument("--list", action="store_true", help="Lista apps disponibles y su estado en Metabase")
      args = parser.parse_args()

      registry_root = os.environ.get(
          "FN_REGISTRY_ROOT",
          os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")),
      )

      if args.list:
          client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)
          dbs = metabase_list_databases(client)
          db_names = {d["name"] for d in dbs}
          apps = find_apps(registry_root)
          print(f"Apps con operations.db ({len(apps)}):\n")
          for app in apps:
              name = db_name_for_app(app)
              status = "registrada" if name in db_names else "NO registrada"
              print(f"  {app:30s} -> {name:30s} [{status}]")
          client.close()
          return

      if not args.app_name:
          parser.error("Se requiere app_name o --list")

      app_name = args.app_name
      ops_path = os.path.join(registry_root, "apps", app_name, "operations.db")
      if not os.path.isfile(ops_path):
          print(f"ERROR: No existe {ops_path}")
          print(f"Apps disponibles: {', '.join(find_apps(registry_root))}")
          sys.exit(1)

      name = db_name_for_app(app_name)
      container_path = container_path_for_app(app_name)

      print(f"Registrando {app_name} en Metabase...")
      print(f"  Nombre:    {name}")
      print(f"  Path:      {container_path}")

      client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)

      # Verificar si ya existe
      dbs = metabase_list_databases(client)
      for db in dbs:
          if db["name"] == name:
              print(f"  Ya existe con id={db['id']}, nada que hacer.")
              client.close()
              return

      # Registrar
      result = metabase_add_database(client, name, "sqlite", {"db": container_path})
      db_id = result["id"]
      print(f"  Registrada con id={db_id}")

      # Recordar: el contenedor necesita el volumen montado
      print(f"\nIMPORTANTE: El contenedor Metabase debe tener montado:")
      print(f"  -v {os.path.join(registry_root, 'apps', app_name)}:{CONTAINER_DATA_PREFIX}{app_name.replace('_', '-')}")
      print(f"\nSi el mount no existe, hay que recrear el contenedor.")
      print(f"\nDatabase disponible en: {METABASE_URL}/question#new?database={db_id}")

      client.close()


  if __name__ == "__main__":
      main()

params_schema:
  {"params":[{"name":"app_name","desc":"Nombre de la app cuya operations.db registrar en Metabase"}],"output":"Void - registra la operations.db en Metabase como database SQLite y muestra mount requerido"}


id: metabase_create_ops_dashboard_py_pipelines
name: metabase_create_ops_dashboard
kind: pipeline
lang: py
domain: pipelines
version: 1.0.0
purity: impure
signature: metabase_create_ops_dashboard(app_name: str) -> None
tags: ["metabase","operations","dashboard","pipeline","infra","launcher"]
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/pipelines/metabase_create_ops_dashboard.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 781cd39262ff2a4b327855b774c2805538b9127eb7e099efb28e8399bd695bb8
description:
  Crea dashboard operativo en Metabase para una app: KPIs de entities/relations/executions/assertions, distribucion por status y tipo, relaciones frecuentes, resultados de ejecuciones y assertions.

uses_functions:
  ["metabase_auth_py_infra","metabase_list_databases_py_infra","metabase_create_card_py_infra","metabase_create_dashboard_py_infra","metabase_update_dashboard_py_infra","metabase_list_dashboards_py_infra","metabase_delete_dashboard_py_infra"]

example:
  ```bash
  # Dashboard para una app
  python python/functions/pipelines/metabase_create_ops_dashboard.py docker_tui

  # Dashboards para todas las apps registradas
  python python/functions/pipelines/metabase_create_ops_dashboard.py --all
  ```

documentation:
  ## Cards del dashboard

  | Fila | Cards | Tipo |
  |------|-------|------|
  | 0 | Entities, Relations, Executions, Assertions | scalar (KPIs) |
  | 5 | Entities por Status, Entities por Tipo, Relations por Status | pie/bar |
  | 13 | Executions Success/Failure, Assertions por Severity, Assertion Results | pie/bar |
  | 22 | Top Relaciones (via), Executions Recientes | row/table |
  | 30 | Lista de Entities | table |
  | 38 | Assertion Results Detalle | table |

  ## Flujo

  1. `metabase_auth` - autentica contra Metabase
  2. `metabase_list_databases` - busca la database operacional de la app
  3. `metabase_create_card` x 14 - crea las cards con queries SQL nativas
  4. `metabase_create_dashboard` - crea el dashboard
  5. `metabase_update_dashboard` - posiciona las cards en el grid

  ## Requisitos

  La database operacional debe estar registrada previamente con `metabase_add_ops_db`.

  Las tablas `executions`, `assertions` y `assertion_results` requieren la migracion 002. Si no existen, las cards correspondientes mostraran error (no rompen el dashboard).

  ## Convencion

  - Dashboard name: `ops: <app-name>`
  - Si el dashboard ya existe, se elimina y recrea.

code:
  """Pipeline: crea dashboard operativo en Metabase para una app.

  Compone: metabase_auth + metabase_list_databases + metabase_create_card
           + metabase_create_dashboard + metabase_update_dashboard.

  Genera un dashboard estandar con KPIs, distribucion y tablas sobre la
  operations.db de una app: entities, relations, executions, assertions.

  Uso:
      python metabase_create_ops_dashboard.py <app_name>
      python metabase_create_ops_dashboard.py docker_tui
      python metabase_create_ops_dashboard.py --all
  """

  import argparse
  import os
  import sys

  sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

  from metabase.client import metabase_auth
  from metabase import (
      metabase_list_databases,
      metabase_create_card,
      metabase_create_dashboard,
      metabase_update_dashboard,
      metabase_list_dashboards,
      metabase_delete_dashboard,
  )

  METABASE_URL = os.environ.get("METABASE_URL", "http://localhost:3000")
  EMAIL = os.environ.get("METABASE_ADMIN_EMAIL", "admin@fnregistry.local")
  PASSWORD = os.environ.get("METABASE_ADMIN_PASSWORD", "FnRegistry2024!")


  def dashboard_name(app_name: str) -> str:
      return f"ops: {app_name.replace('_', '-')}"


  def db_name_for_app(app_name: str) -> str:
      return f"ops-{app_name.replace('_', '-')}"


  def build_cards(app_name: str) -> list[dict]:
      """Define las cards del dashboard operativo."""
      return [
          # ---- Fila 0: KPIs (h=5) ----
          {
              "name": f"[{app_name}] Entities",
              "display": "scalar",
              "sql": "SELECT COUNT(*) FROM entities;",
              "size_x": 6, "size_y": 5, "col": 0, "row": 0,
          },
          {
              "name": f"[{app_name}] Relations",
              "display": "scalar",
              "sql": "SELECT COUNT(*) FROM relations;",
              "size_x": 6, "size_y": 5, "col": 6, "row": 0,
          },
          {
              "name": f"[{app_name}] Executions",
              "display": "scalar",
              "sql": "SELECT COUNT(*) FROM executions;",
              "size_x": 6, "size_y": 5, "col": 12, "row": 0,
          },
          {
              "name": f"[{app_name}] Assertions",
              "display": "scalar",
              "sql": "SELECT COUNT(*) FROM assertions;",
              "size_x": 6, "size_y": 5, "col": 18, "row": 0,
          },
          # ---- Fila 5: Distribucion (h=8) ----
          {
              "name": f"[{app_name}] Entities por Status",
              "display": "pie",
              "sql": "SELECT status, COUNT(*) AS cantidad FROM entities GROUP BY status ORDER BY cantidad DESC;",
              "size_x": 8, "size_y": 8, "col": 0, "row": 5,
          },
          {
              "name": f"[{app_name}] Entities por Tipo",
              "display": "bar",
              "sql": "SELECT type_ref, COUNT(*) AS cantidad FROM entities GROUP BY type_ref ORDER BY cantidad DESC;",
              "size_x": 8, "size_y": 8, "col": 8, "row": 5,
          },
          {
              "name": f"[{app_name}] Relations por Status",
              "display": "pie",
              "sql": "SELECT status, COUNT(*) AS cantidad FROM relations GROUP BY status ORDER BY cantidad DESC;",
              "size_x": 8, "size_y": 8, "col": 16, "row": 5,
          },
          # ---- Fila 13: Executions y Assertions (h=9) ----
          {
              "name": f"[{app_name}] Executions: Success vs Failure",
              "display": "pie",
              "sql": "SELECT status, COUNT(*) AS cantidad FROM executions GROUP BY status ORDER BY cantidad DESC;",
              "size_x": 8, "size_y": 9, "col": 0, "row": 13,
          },
          {
              "name": f"[{app_name}] Assertions por Severity",
              "display": "bar",
              "sql": "SELECT severity, COUNT(*) AS cantidad FROM assertions GROUP BY severity ORDER BY cantidad DESC;",
              "size_x": 8, "size_y": 9, "col": 8, "row": 13,
          },
          {
              "name": f"[{app_name}] Assertion Results",
              "display": "pie",
              "sql": "SELECT status, COUNT(*) AS cantidad FROM assertion_results GROUP BY status ORDER BY cantidad DESC;",
              "size_x": 8, "size_y": 9, "col": 16, "row": 13,
          },
          # ---- Fila 22: Relaciones frecuentes (h=8) ----
          {
              "name": f"[{app_name}] Top Relaciones (via)",
              "display": "row",
              "sql": """
                  SELECT
                      CASE WHEN via = '' THEN '(directo)' ELSE via END AS via_funcion,
                      COUNT(*) AS cantidad
                  FROM relations
                  GROUP BY via
                  ORDER BY cantidad DESC
                  LIMIT 15;
              """,
              "size_x": 12, "size_y": 8, "col": 0, "row": 22,
          },
          {
              "name": f"[{app_name}] Executions Recientes",
              "display": "table",
              "sql": """
                  SELECT
                      id,
                      pipeline_id,
                      status,
                      duration_ms,
                      records_in,
                      records_out,
                      CASE WHEN error = '' THEN '-' ELSE error END AS error,
                      started_at
                  FROM executions
                  ORDER BY started_at DESC
                  LIMIT 20;
              """,
              "size_x": 12, "size_y": 8, "col": 12, "row": 22,
          },
          # ---- Fila 30: Tablas detalle (h=8) ----
          {
              "name": f"[{app_name}] Lista de Entities",
              "display": "table",
              "sql": """
                  SELECT
                      id,
                      name,
                      type_ref,
                      status,
                      domain,
                      source,
                      created_at,
                      updated_at
                  FROM entities
                  ORDER BY updated_at DESC;
              """,
              "size_x": 24, "size_y": 8, "col": 0, "row": 30,
          },
          {
              "name": f"[{app_name}] Assertion Results Detalle",
              "display": "table",
              "sql": """
                  SELECT
                      ar.id,
                      a.name AS assertion,
                      a.kind,
                      a.severity,
                      ar.status,
                      ar.value,
                      ar.message,
                      ar.evaluated_at
                  FROM assertion_results ar
                  JOIN assertions a ON a.id = ar.assertion_id
                  ORDER BY ar.evaluated_at DESC
                  LIMIT 50;
              """,
              "size_x": 24, "size_y": 8, "col": 0, "row": 38,
          },
      ]


  def create_ops_dashboard(client, app_name: str, db_id: int) -> int:
      """Crea el dashboard operativo para una app. Retorna dashboard_id."""
      dash_name = dashboard_name(app_name)

      # Eliminar dashboard existente si hay
      for d in metabase_list_dashboards(client):
          if d.get("name") == dash_name:
              print(f"  Dashboard existente (id={d['id']}), recreando...")
              metabase_delete_dashboard(client, d["id"])

      cards_def = build_cards(app_name)

      print(f"  Creando {len(cards_def)} cards...")
      created = []
      for i, card_def in enumerate(cards_def):
          card = metabase_create_card(
              client,
              name=card_def["name"],
              dataset_query={
                  "database": db_id,
                  "type": "native",
                  "native": {"query": card_def["sql"]},
              },
              display=card_def["display"],
              description=f"ops dashboard: {app_name}",
          )
          created.append((card, card_def))
          print(f"    [{i+1}/{len(cards_def)}] {card_def['name']} (id={card['id']})")

      print("  Creando dashboard...")
      dashboard = metabase_create_dashboard(
          client,
          name=dash_name,
          description=f"Dashboard operativo de {app_name}: entities, relations, executions, assertions.",
      )
      dash_id = dashboard["id"]

      dashcards = []
      for idx, (card, card_def) in enumerate(created):
          dashcards.append({
              "id": -(idx + 1),
              "card_id": card["id"],
              "size_x": card_def["size_x"],
              "size_y": card_def["size_y"],
              "col": card_def["col"],
              "row": card_def["row"],
          })

      metabase_update_dashboard(client, dash_id, dashcards=dashcards)
      return dash_id


  def main():
      parser = argparse.ArgumentParser(description="Crea dashboard operativo en Metabase para una app")
      parser.add_argument("app_name", nargs="?", help="Nombre de la app")
      parser.add_argument("--all", action="store_true", help="Crea dashboards para todas las apps registradas en Metabase")
      args = parser.parse_args()

      if not args.app_name and not args.all:
          parser.error("Se requiere app_name o --all")

      print("Autenticando en Metabase...")
      client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)
      dbs = metabase_list_databases(client)

      if args.all:
          ops_dbs = [
              (db["name"].replace("ops-", "").replace("-", "_"), db["id"])
              for db in dbs
              if db["name"].startswith("ops-")
          ]
          if not ops_dbs:
              print("No hay databases operacionales registradas en Metabase.")
              sys.exit(1)

          print(f"Creando dashboards para {len(ops_dbs)} apps...\n")
          for app_name, db_id in ops_dbs:
              print(f"--- {app_name} (db_id={db_id}) ---")
              dash_id = create_ops_dashboard(client, app_name, db_id)
              print(f"  Dashboard: {METABASE_URL}/dashboard/{dash_id}\n")
      else:
          app_name = args.app_name
          expected_db_name = f"ops-{app_name.replace('_', '-')}"
          db_id = None
          for db in dbs:
              if db["name"] == expected_db_name:
                  db_id = db["id"]
                  break

          if not db_id:
              print(f"ERROR: Database '{expected_db_name}' no encontrada en Metabase.")
              print("Databases disponibles:")
              for db in dbs:
                  print(f"  - {db['name']} (id={db['id']})")
              print(f"\nRegistra primero con: python metabase_add_ops_db.py {app_name}")
              sys.exit(1)

          print(f"App: {app_name} (db_id={db_id})")
          dash_id = create_ops_dashboard(client, app_name, db_id)
          print(f"\nDashboard listo: {METABASE_URL}/dashboard/{dash_id}")

      client.close()


  if __name__ == "__main__":
      main()

params_schema:
  {"params":[{"name":"app_name","desc":"Nombre de la app para la que crear dashboard operativo"}],"output":"Void - crea dashboard en Metabase con KPIs, distribuciones y tablas de entities/executions/assertions"}


id: metabase_fix_permissions_py_pipelines
name: metabase_fix_permissions
kind: pipeline
lang: py
domain: pipelines
version: 1.0.0
purity: impure
signature: metabase_fix_permissions() -> None
tags: ["metabase","sqlite","permissions","docker","pipeline","infra","launcher"]
uses_functions: ["metabase_auth_py_infra","metabase_list_databases_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/pipelines/metabase_fix_permissions.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 6dfe42e666545a0e1e36ca227d29ef34a400f10571a9fb273e1f6188554670f1
description:
  Arregla permisos SQLITE_READONLY_DIRECTORY en el contenedor Metabase. Hace chmod 777/666 en directorios y archivos .db bajo /data/ para que el usuario metabase (UID 2000) pueda crear journal files.

example:
  ```bash
  # Fix permisos (usa contenedor por defecto: fn_registry-metabase)
  python python/functions/pipelines/metabase_fix_permissions.py

  # Contenedor custom
  python python/functions/pipelines/metabase_fix_permissions.py --container mi-metabase
  ```

documentation:
  ## Problema

  Metabase corre Java como UID 2000 (usuario `metabase`). SQLite necesita crear
  journal/WAL files en el mismo directorio que la BD. Si el directorio no es
  escribible por UID 2000, Metabase devuelve:

  ```
  SQLITE_READONLY_DIRECTORY: Process does not have permission to create a
  journal file in the same directory as the database
  ```

  ## Solucion

  El pipeline ejecuta dentro del contenedor (como root):
  - `chmod 777` en cada directorio que contiene un `.db`
  - `chmod 666` en cada archivo `.db`, `-wal`, `-shm`, `-journal`

  Esto NO cambia ownership (evita que `chown` se propague al host via bind mount).

  ## Cuando ejecutar

  - Despues de recrear el contenedor Metabase
  - Despues de añadir una nueva database con `metabase_add_ops_db`
  - Cuando Metabase muestra error SQLITE_READONLY_DIRECTORY

code:
  """Pipeline: arregla permisos de SQLite en el contenedor Metabase.

  Compone: metabase_auth + metabase_list_databases (para verificar).

  SQLite necesita escribir journal/WAL files en el mismo directorio que la BD.
  El contenedor Metabase corre Java como UID 2000 (usuario metabase).
  Si los directorios o archivos no son escribibles por ese UID, Metabase
  devuelve SQLITE_READONLY_DIRECTORY.

  Este pipeline:
  1. Detecta el contenedor Metabase
  2. Encuentra todos los directorios /data/ que contienen .db
  3. Hace chmod 777 en directorios y chmod 666 en archivos .db
  4. Verifica que Metabase puede leer cada database

  Uso:
      python metabase_fix_permissions.py
      python metabase_fix_permissions.py --container fn_registry-metabase
  """

  import argparse
  import os
  import subprocess
  import sys

  sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

  from metabase.client import metabase_auth
  from metabase import metabase_list_databases

  METABASE_URL = os.environ.get("METABASE_URL", "http://localhost:3000")
  EMAIL = os.environ.get("METABASE_ADMIN_EMAIL", "admin@fnregistry.local")
  PASSWORD = os.environ.get("METABASE_ADMIN_PASSWORD", "FnRegistry2024!")
  DEFAULT_CONTAINER = "fn_registry-metabase"


  def docker_exec(container: str, cmd: list[str], user: str = "root") -> str:
      """Ejecuta comando dentro del contenedor."""
      result = subprocess.run(
          ["docker", "exec", "-u", user, container] + cmd,
          capture_output=True, text=True,
      )
      return result.stdout.strip()


  def fix_container_permissions(container: str) -> list[str]:
      """Arregla permisos de todos los .db en /data/ del contenedor.

      Returns:
          Lista de paths arreglados.
      """
      # Encontrar todos los .db bajo /data/
      find_output = docker_exec(container, ["find", "/data", "-name", "*.db", "-type", "f"])
      if not find_output:
          print("  No se encontraron archivos .db en /data/")
          return []

      db_files = [f.strip() for f in find_output.splitlines() if f.strip()]
      fixed = []

      for db_path in db_files:
          db_dir = os.path.dirname(db_path)

          # Fix directorio: 777 para que UID 2000 pueda crear journal
          docker_exec(container, ["chmod", "777", db_dir])

          # Fix archivo: 666 para que UID 2000 pueda leer/escribir
          docker_exec(container, ["chmod", "666", db_path])

          # Fix WAL/SHM si existen
          for suffix in ["-wal", "-shm", "-journal"]:
              wal_path = db_path + suffix
              docker_exec(container, ["chmod", "666", wal_path])

          fixed.append(db_path)
          print(f"  Fixed: {db_path} (dir: {db_dir})")

      return fixed


  def verify_databases(client) -> bool:
      """Verifica que todas las databases SQLite responden."""
      import httpx

      dbs = metabase_list_databases(client)
      all_ok = True

      for db in dbs:
          if db.get("engine") != "sqlite":
              continue

          name = db["name"]
          db_id = db["id"]

          try:
              resp = client._http.request("POST", "/api/dataset", json={
                  "database": db_id,
                  "type": "native",
                  "native": {"query": "SELECT 1"},
              }, extensions={"timeout": {"read": 60, "write": 10, "connect": 10, "pool": 10}})

              if resp.status_code in (200, 202):
                  print(f"  OK: {name} (id={db_id})")
              else:
                  error = resp.text[:150]
                  print(f"  FAIL: {name} (id={db_id}) -> {error}")
                  all_ok = False
          except httpx.ReadTimeout:
              print(f"  TIMEOUT: {name} (id={db_id}) -> Metabase aun sincronizando, reintentar en unos segundos")
              all_ok = False

      return all_ok


  def main():
      parser = argparse.ArgumentParser(description="Arregla permisos SQLite en contenedor Metabase")
      parser.add_argument("--container", default=DEFAULT_CONTAINER, help="Nombre del contenedor Metabase")
      args = parser.parse_args()

      container = args.container

      # Verificar que el contenedor existe
      result = subprocess.run(
          ["docker", "inspect", container, "--format", "{{.State.Running}}"],
          capture_output=True, text=True,
      )
      if result.returncode != 0 or result.stdout.strip() != "true":
          print(f"ERROR: Contenedor '{container}' no esta corriendo.")
          sys.exit(1)

      print(f"Contenedor: {container}")
      print(f"\n1. Arreglando permisos en /data/...")
      fixed = fix_container_permissions(container)

      if not fixed:
          print("  Nada que arreglar.")
          return

      print(f"\n2. Verificando databases en Metabase...")
      client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)
      ok = verify_databases(client)
      client.close()

      if ok:
          print(f"\nTodas las databases responden correctamente.")
      else:
          print(f"\nAlgunas databases siguen fallando. Revisar logs del contenedor:")
          print(f"  docker logs {container} --tail 50")


  if __name__ == "__main__":
      main()

params_schema:
  {"output":"Void - arregla permisos SQLITE_READONLY_DIRECTORY en contenedor Metabase vía chmod 777/666"}


id: monte_carlo_market_py_pipelines
name: monte_carlo_market
kind: pipeline
lang: py
domain: pipelines
version: 1.0.0
purity: impure
tags: ["montecarlo","simulation","market","launcher","finance","microstructure"]
uses_functions: ["run_market_sim_py_pipelines"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["numpy"]
tests: []
file_path: python/functions/pipelines/monte_carlo_market.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 927980627a68c303b1d87a77345c99cfa2dad303e0c26594d1bf6a9251512614
signature:
  def monte_carlo_market(n_simulations: int, base_params: dict, vary_params: dict, seed_start: int) -> list[dict]

description:
  Ejecuta N simulaciones de mercado con parámetros variados uniformemente. Cada simulación usa run_market_sim y retorna métricas resumen: spreads, trades por tick, volatilidad realizada y PnL total de makers.

example:
  ```bash
  # 10 simulaciones con sigma y gamma variables
  python python/functions/pipelines/monte_carlo_market.py -n 10
  ```

  ```python
  from monte_carlo_market import monte_carlo_market

  results = monte_carlo_market(
      n_simulations=50,
      base_params={'n_ticks': 300, 'n_makers': 3},
      vary_params={
          'sigma': (0.005, 0.05),
          'gamma': (0.01, 1.0),
          'hawkes_alpha': (0.1, 0.9),
      },
      seed_start=42,
  )
  # Cada resultado tiene: sim_id, seed, sigma, gamma, hawkes_alpha,
  # total_trades, mean_spread, std_spread, mean_trades_per_tick,
  # price_return, maker_total_pnl, realized_vol
  ```

notes:
  `vary_params` acepta cualquier parámetro válido de `run_market_sim` como clave, con valor `(min, max)`.
  Los parámetros en `base_params` tienen precedencia sobre los defaults pero son sobreescritos por `vary_params`.

documentation:
  ## Flujo

  1. Para cada simulación i en range(n_simulations):
     - Tomar `base_params` + `seed = seed_start + i`
     - Samplear `vary_params` uniformemente con rng derivado de `seed_start`
     - Llamar `run_market_sim(**params)`
     - Calcular métricas resumen sobre el resultado
  2. Reportar progreso cada 10% de simulaciones
  3. Retornar lista de dicts con params usados + métricas

  ## Métricas por simulación

  | Campo | Descripción |
  |---|---|
  | `total_trades` | Número total de trades en la simulación |
  | `mean_spread` | Spread bid-ask medio |
  | `std_spread` | Desviación estándar del spread |
  | `mean_trades_per_tick` | Intensidad media del flujo de órdenes |
  | `price_return` | Retorno % del precio fundamental |
  | `maker_total_pnl` | PnL agregado de todos los makers |
  | `realized_vol` | Volatilidad realizada de los trade prices (si hay trades) |

code:
  """Ejecuta N simulaciones de mercado con parámetros variables para análisis Monte Carlo."""

  import sys
  import os
  import json


  def monte_carlo_market(
      n_simulations: int = 100,
      base_params: dict | None = None,
      vary_params: dict | None = None,
      seed_start: int = 0,
  ) -> list[dict]:
      """Ejecuta N simulaciones variando parámetros.

      base_params: parámetros fijos para run_market_sim
      vary_params: dict de param_name -> (min, max) para variar uniformemente

      Retorna lista de dicts, cada uno con los params usados + métricas resumen.
      """
      import numpy as np

      sys.path.insert(0, os.path.join(os.environ.get('FN_REGISTRY_ROOT', os.path.expanduser('~/fn_registry')), 'python', 'functions'))
      sys.path.insert(0, os.path.join(os.environ.get('FN_REGISTRY_ROOT', os.path.expanduser('~/fn_registry')), 'python', 'functions', 'pipelines'))
      from run_market_sim import run_market_sim

      if base_params is None:
          base_params = {}
      if vary_params is None:
          vary_params = {}

      rng = np.random.default_rng(seed_start)
      results = []

      for i in range(n_simulations):
          params = dict(base_params)
          params['seed'] = seed_start + i

          # Variar parámetros
          varied = {}
          for pname, (pmin, pmax) in vary_params.items():
              val = rng.uniform(pmin, pmax)
              params[pname] = round(val, 6)
              varied[pname] = params[pname]

          sim = run_market_sim(**params)

          # Métricas resumen
          spreads = sim['spreads']
          trade_prices = sim['trade_prices']
          n_per_tick = sim['n_trades_per_tick']

          result = {
              'sim_id': i,
              'seed': params['seed'],
              **varied,
              'total_trades': sim['total_trades'],
              'mean_spread': round(np.mean(spreads), 6) if spreads else 0,
              'std_spread': round(np.std(spreads), 6) if spreads else 0,
              'mean_trades_per_tick': round(np.mean(n_per_tick), 2),
              'price_return': round((sim['fundamental_prices'][-1] / sim['fundamental_prices'][0] - 1) * 100, 4),
              'maker_total_pnl': round(sum(sim['maker_pnls']), 2),
          }

          if trade_prices:
              tp = np.array(trade_prices)
              log_ret = np.diff(np.log(tp[tp > 0]))
              if len(log_ret) > 1:
                  result['realized_vol'] = round(float(np.std(log_ret)), 6)

          results.append(result)

          if (i + 1) % max(1, n_simulations // 10) == 0:
              print(f'  {i+1}/{n_simulations} simulaciones completadas')

      return results


  if __name__ == '__main__':
      import argparse
      parser = argparse.ArgumentParser()
      parser.add_argument('-n', type=int, default=10)
      args = parser.parse_args()

      results = monte_carlo_market(
          n_simulations=args.n,
          base_params={'n_ticks': 200},
          vary_params={'sigma': (0.005, 0.05), 'gamma': (0.01, 1.0)},
      )
      print(json.dumps(results[-1], indent=2))
      print(f'\n{len(results)} simulaciones completadas')

params_schema:
  {"params":[{"name":"n_simulations","desc":"Número de simulaciones de mercado a ejecutar"},{"name":"base_params","desc":"Parámetros base aplicados a todas las simulaciones"},{"name":"vary_params","desc":"Parámetros a variar uniformemente: {param: (min, max)}"},{"name":"seed_start","desc":"Seed inicial para generador aleatorio (se incrementa por simulación)"}],"output":"Lista de dicts con parámetros usados y métricas: spreads, trades, volatilidad, PnL"}


id: run_market_sim_py_pipelines
name: run_market_sim
kind: pipeline
lang: py
domain: pipelines
version: 1.0.0
purity: impure
tags: ["simulation","market","matching-engine","montecarlo","launcher","finance","microstructure"]
uses_functions: ["generate_gbm_prices_py_finance","avellaneda_stoikov_quotes_py_finance"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["numpy"]
tests: []
file_path: python/functions/pipelines/run_market_sim.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 6eca9928c5ab0d24283d3d18660aff9d425bfeab55e272112b41341b9df0ddc8
signature:
  def run_market_sim(initial_price: float, n_ticks: int, sigma: float, mu: float, jump_intensity: float, jump_size_std: float, n_makers: int, maker_spread: float, gamma: float, maker_levels: int, maker_qty: float, n_takers_lambda: float, taker_size_alpha: float, taker_size_min: float, taker_size_max: float, hawkes_alpha: float, hawkes_beta: float, seed: int) -> dict

description:
  Simula un mercado completo con matching engine FIFO. Makers usan Avellaneda-Stoikov, takers llegan según proceso Hawkes con tamaños power-law. Retorna trades, spreads, midprices y PnL de makers.

example:
  ```bash
  python python/functions/pipelines/run_market_sim.py
  # {
  #   "total_trades": 1234,
  #   "mean_spread": 0.4821,
  #   "maker_pnls": [12.5, -3.2, 8.1, 5.6, -1.4]
  # }
  ```

  ```python
  from run_market_sim import run_market_sim

  result = run_market_sim(
      initial_price=100.0,
      n_ticks=200,
      sigma=0.01,
      n_makers=3,
      seed=0,
  )
  print(result['total_trades'])
  print(result['maker_pnls'])
  ```

notes:
  Los parámetros Hawkes (`hawkes_alpha`, `hawkes_beta`) controlan la autocorrelación del flujo de órdenes.
  `branching_ratio = hawkes_alpha / hawkes_beta`; si > 1, el proceso es explosivo.
  El matching es simplificado: no hay cancelaciones intra-tick, el book se reconstituye en cada tick.

documentation:
  ## Flujo

  1. `generate_gbm_prices` — genera la serie de precios fundamentales con GBM + saltos
  2. Loop por ticks:
     - Cada maker coloca quotes via `avellaneda_stoikov_quotes`
     - Takers llegan según Poisson con intensidad modulada por excitación Hawkes
     - Tamaños de taker siguen distribución Pareto (power-law)
     - Matching FIFO sobre el order book simplificado
     - Excitación Hawkes decae exponencialmente entre ticks
  3. Mark-to-market final de inventarios de makers

code:
  """Ejecuta una simulación de mercado completa con matching engine FIFO."""

  import sys
  import os
  import json


  def run_market_sim(
      initial_price: float = 100.0,
      n_ticks: int = 500,
      sigma: float = 0.02,
      mu: float = 0.0,
      jump_intensity: float = 0.02,
      jump_size_std: float = 0.05,
      n_makers: int = 5,
      maker_spread: float = 0.5,
      gamma: float = 0.1,
      maker_levels: int = 3,
      maker_qty: float = 10.0,
      n_takers_lambda: float = 2.0,
      taker_size_alpha: float = 2.0,
      taker_size_min: float = 1.0,
      taker_size_max: float = 100.0,
      hawkes_alpha: float = 0.5,
      hawkes_beta: float = 1.0,
      seed: int = 42,
  ) -> dict:
      """Simula un mercado con makers (Avellaneda-Stoikov) y takers (Hawkes + power-law).

      Retorna dict con:
        - trade_prices, trade_times, trade_sizes: listas de trades
        - spreads, midprices: series por tick
        - n_trades_per_tick: arrivals por tick
        - maker_pnls: PnL final de cada maker
        - total_trades: conteo total
      """
      import numpy as np

      # Importar funciones del registry
      sys.path.insert(0, os.path.join(os.environ.get('FN_REGISTRY_ROOT', os.path.expanduser('~/fn_registry')), 'python', 'functions'))
      from finance.finance import generate_gbm_prices, avellaneda_stoikov_quotes

      rng = np.random.default_rng(seed)

      # Generar precios fundamentales
      fund_prices = generate_gbm_prices(initial_price, n_ticks, sigma, mu, jump_intensity, jump_size_std, seed)

      # Order book simplificado: listas de (price, qty, maker_idx)
      # Matching inline para no depender del notebook
      trade_prices, trade_times, trade_sizes = [], [], []
      spreads, midprices = [], []
      n_trades_per_tick = []
      maker_inventories = [0.0] * n_makers
      maker_pnls = [0.0] * n_makers
      hawkes_excitation = 0.0

      for t in range(n_ticks):
          mid = fund_prices[t]

          # Makers place orders
          all_bids = []  # (price, qty, maker_idx)
          all_asks = []
          for m in range(n_makers):
              noise = rng.uniform(-0.05, 0.05)
              quotes = avellaneda_stoikov_quotes(
                  mid + noise, maker_inventories[m], gamma, sigma, maker_spread, maker_levels, maker_qty
              )
              for q in quotes:
                  if q['side'] == 'buy':
                      all_bids.append((q['price'], q['qty'], m))
                  else:
                      all_asks.append((q['price'], q['qty'], m))

          all_bids.sort(key=lambda x: -x[0])  # best bid first
          all_asks.sort(key=lambda x: x[0])    # best ask first

          # Record book state
          if all_bids and all_asks:
              spreads.append(all_asks[0][0] - all_bids[0][0])
              midprices.append((all_bids[0][0] + all_asks[0][0]) / 2)
          else:
              spreads.append(0.0)
              midprices.append(mid)

          # Takers arrive (Hawkes)
          lam = max(0.1, n_takers_lambda + hawkes_excitation)
          n_takers = rng.poisson(lam)
          tick_trades = 0

          for _ in range(n_takers):
              side = 'buy' if rng.random() < 0.5 else 'sell'
              raw_size = (rng.pareto(taker_size_alpha) + 1) * taker_size_min
              qty_remaining = min(round(raw_size, 1), taker_size_max)

              book = list(all_asks) if side == 'buy' else list(all_bids)

              for i, (price, available, maker_idx) in enumerate(book):
                  if qty_remaining <= 0:
                      break
                  fill = min(qty_remaining, available)
                  trade_prices.append(price)
                  trade_times.append(t)
                  trade_sizes.append(fill)
                  tick_trades += 1
                  qty_remaining -= fill

                  if side == 'buy':
                      maker_inventories[maker_idx] -= fill
                      maker_pnls[maker_idx] += price * fill
                  else:
                      maker_inventories[maker_idx] += fill
                      maker_pnls[maker_idx] -= price * fill

                  book[i] = (price, available - fill, maker_idx)

              if side == 'buy':
                  all_asks = [(p, q, m) for p, q, m in book if q > 0]
              else:
                  all_bids = [(p, q, m) for p, q, m in book if q > 0]

          hawkes_excitation *= np.exp(-hawkes_beta)
          hawkes_excitation += hawkes_alpha * tick_trades
          n_trades_per_tick.append(tick_trades)

      # Mark to market
      final_price = fund_prices[-1]
      for m in range(n_makers):
          maker_pnls[m] += maker_inventories[m] * final_price

      return {
          'trade_prices': trade_prices,
          'trade_times': trade_times,
          'trade_sizes': trade_sizes,
          'spreads': spreads,
          'midprices': midprices,
          'n_trades_per_tick': n_trades_per_tick,
          'fundamental_prices': fund_prices,
          'maker_pnls': [round(p, 2) for p in maker_pnls],
          'total_trades': len(trade_prices),
      }


  if __name__ == '__main__':
      result = run_market_sim()
      print(json.dumps({
          'total_trades': result['total_trades'],
          'mean_spread': round(sum(result['spreads']) / len(result['spreads']), 4),
          'maker_pnls': result['maker_pnls'],
      }, indent=2))

params_schema:
  {"params":[{"name":"initial_price","desc":"Precio inicial del activo"},{"name":"n_ticks","desc":"Número de ticks a simular"},{"name":"sigma","desc":"Volatilidad del precio fundamental"},{"name":"mu","desc":"Drift del precio fundamental"},{"name":"jump_intensity","desc":"Intensidad de saltos de Poisson"},{"name":"jump_size_std","desc":"Desviación estándar de tamaños de salto"},{"name":"n_makers","desc":"Número de market makers"},{"name":"gamma","desc":"Parámetro de aversión al riesgo de Avellaneda-Stoikov"},{"name":"hawkes_alpha","desc":"Parámetro de excitación del proceso Hawkes"},{"name":"seed","desc":"Seed para reproducibilidad"}],"output":"Dict con trades, spreads, midprices, PnL de makers y estadísticas del matching"}


# types

id: cron_schedule_go_core
name: cron_schedule
lang: go
domain: core
version: 1.0.0
algebraic: product
tags: ["cron","schedule","scheduling","time","parsed"]
uses_types: []
file_path: functions/core/cron_schedule.go
created_at: 2026-04-05T11:24:00Z
updated_at: 2026-04-05T11:24:00Z
content_hash: a78f4dfc6c9291f8ac874e0c6caa6cda0047d3ba842aa768c621b1d932ae299c
definition:
  type CronSchedule struct {
      Minute     []int
      Hour       []int
      DayOfMonth []int
      Month      []int
      DayOfWeek  []int
      Raw        string
  }

description:
  Representacion de una expresion cron parseada con los valores expandidos por campo. Raw preserva la expresion original para debug.

notes:
  Tipo producto inmutable por convencion. Cada campo contiene la lista completa de valores validos para ese campo (ej: Minute=[0,15,30,45] para `*/15`). Producido por ParseCronExpr, consumido por NextCronTime y CronTicker.

code:
  package core

  // CronSchedule represents a parsed cron expression with expanded field values.
  type CronSchedule struct {
  	Minute     []int
  	Hour       []int
  	DayOfMonth []int
  	Month      []int
  	DayOfWeek  []int
  	Raw        string // original expression
  }


id: dag_continue_on_go_core
name: dag_continue_on
lang: go
domain: core
version: 1.0.0
algebraic: product
definition:
  type DagContinueOn struct {
  Failure bool
  Skipped bool
  }

tags: ["dag","workflow","policy","error-handling"]
uses_types: []
file_path: functions/core/dag_definition.go
created_at: 2026-04-12T11:03:11Z
updated_at: 2026-04-12T11:03:11Z
notes: Tipo producto. Embebido en DagStep. Corresponde al campo `continue_on` del YAML de Dagu.
content_hash: 2199c7eb26d32e0cd716355b018f1a216fb17764808f7a28b8a6bc26cc757a95
description:
  Politica de continuacion de un step DAG ante fallos o saltos. Cuando Failure=true el DAG no se detiene si el step falla. Cuando Skipped=true tampoco se detiene si el step es saltado.

code:
  package core

  // DagContinueOn controls whether a step continues on failure/skip.
  type DagContinueOn struct {
  	Failure bool
  	Skipped bool
  }

  // DagRetryPolicy configures automatic retries for a step.
  type DagRetryPolicy struct {
  	Limit       int
  	IntervalSec int
  }

  // DagStep represents a single step in a DAG workflow.
  type DagStep struct {
  	Name        string
  	ID          string
  	Description string
  	Command     string
  	Script      string
  	Args        []string
  	Shell       string
  	Dir         string
  	Depends     []string
  	Env         map[string]string
  	ContinueOn  DagContinueOn
  	RetryPolicy DagRetryPolicy
  	TimeoutSec  int
  	Output      string
  	Tags        []string
  }

  // DagHandlers contains lifecycle handler steps.
  type DagHandlers struct {
  	Init    []DagStep
  	Success []DagStep
  	Failure []DagStep
  	Exit    []DagStep
  }

  // DagDefinition is a complete DAG workflow parsed from YAML.
  type DagDefinition struct {
  	Name        string
  	Description string
  	Group       string
  	Type        string // "graph" or "" (chain/sequential)
  	WorkingDir  string
  	Shell       string
  	Env         map[string]string
  	Schedule    []string
  	Steps       []DagStep
  	HandlerOn   DagHandlers
  	Tags        []string
  	TimeoutSec  int
  	FilePath    string
  }

  // DagValidationResult contains validation output.
  type DagValidationResult struct {
  	Valid    bool
  	Errors   []string
  	Warnings []string
  	Levels   [][]string // topological levels (step names/IDs)
  }


id: dag_definition_go_core
name: dag_definition
lang: go
domain: core
version: 1.0.0
algebraic: product
tags: ["dag","workflow","yaml","dagu","definition"]
uses_types: ["dag_step_go_core","dag_handlers_go_core"]
file_path: functions/core/dag_definition.go
created_at: 2026-04-12T11:03:11Z
updated_at: 2026-04-12T11:03:11Z
content_hash: 37f481b8854e12d467e1abd18cb8943f17af2d88b7c92be772a99e06a8712c92
definition:
  type DagDefinition struct {
      Name        string
      Description string
      Group       string
      Type        string
      WorkingDir  string
      Shell       string
      Env         map[string]string
      Schedule    []string
      Steps       []DagStep
      HandlerOn   DagHandlers
      Tags        []string
      TimeoutSec  int
      FilePath    string
  }

description:
  Definicion completa de un workflow DAG parseada desde YAML compatible con Dagu. Contiene steps, handlers de ciclo de vida, variables de entorno, schedule y metadatos del flujo.

notes:
  Tipo producto. Type puede ser "graph" (ejecucion paralela por niveles topologicos) o vacio (cadena secuencial). Schedule es siempre una lista de strings (normalizado desde string o lista en el YAML). FilePath se rellena opcionalmente por el caller para saber el origen del archivo.

code:
  package core

  // DagContinueOn controls whether a step continues on failure/skip.
  type DagContinueOn struct {
  	Failure bool
  	Skipped bool
  }

  // DagRetryPolicy configures automatic retries for a step.
  type DagRetryPolicy struct {
  	Limit       int
  	IntervalSec int
  }

  // DagStep represents a single step in a DAG workflow.
  type DagStep struct {
  	Name        string
  	ID          string
  	Description string
  	Command     string
  	Script      string
  	Args        []string
  	Shell       string
  	Dir         string
  	Depends     []string
  	Env         map[string]string
  	ContinueOn  DagContinueOn
  	RetryPolicy DagRetryPolicy
  	TimeoutSec  int
  	Output      string
  	Tags        []string
  }

  // DagHandlers contains lifecycle handler steps.
  type DagHandlers struct {
  	Init    []DagStep
  	Success []DagStep
  	Failure []DagStep
  	Exit    []DagStep
  }

  // DagDefinition is a complete DAG workflow parsed from YAML.
  type DagDefinition struct {
  	Name        string
  	Description string
  	Group       string
  	Type        string // "graph" or "" (chain/sequential)
  	WorkingDir  string
  	Shell       string
  	Env         map[string]string
  	Schedule    []string
  	Steps       []DagStep
  	HandlerOn   DagHandlers
  	Tags        []string
  	TimeoutSec  int
  	FilePath    string
  }

  // DagValidationResult contains validation output.
  type DagValidationResult struct {
  	Valid    bool
  	Errors   []string
  	Warnings []string
  	Levels   [][]string // topological levels (step names/IDs)
  }


id: dag_handlers_go_core
name: dag_handlers
lang: go
domain: core
version: 1.0.0
algebraic: product
tags: ["dag","workflow","handlers","lifecycle"]
uses_types: ["dag_step_go_core"]
file_path: functions/core/dag_definition.go
created_at: 2026-04-12T11:03:11Z
updated_at: 2026-04-12T11:03:11Z
content_hash: 1dc8884d4c6e839c7990bb834ff1be040afe1cdda9f7b2a850aed493ff68fa55
definition:
  type DagHandlers struct {
      Init    []DagStep
      Success []DagStep
      Failure []DagStep
      Exit    []DagStep
  }

description:
  Handlers de ciclo de vida de un DAG. Cada campo contiene steps que se ejecutan en el evento correspondiente: inicializacion, exito, fallo o salida (siempre). Corresponde a handler_on o handlers en el YAML de Dagu.

notes:
  Tipo producto. Embebido en DagDefinition. Los campos handler_on y handlers son aliases en el YAML de Dagu — ambos se normalizan a este tipo. Cada handler puede ser un step unico o una lista de steps en el YAML.

code:
  package core

  // DagContinueOn controls whether a step continues on failure/skip.
  type DagContinueOn struct {
  	Failure bool
  	Skipped bool
  }

  // DagRetryPolicy configures automatic retries for a step.
  type DagRetryPolicy struct {
  	Limit       int
  	IntervalSec int
  }

  // DagStep represents a single step in a DAG workflow.
  type DagStep struct {
  	Name        string
  	ID          string
  	Description string
  	Command     string
  	Script      string
  	Args        []string
  	Shell       string
  	Dir         string
  	Depends     []string
  	Env         map[string]string
  	ContinueOn  DagContinueOn
  	RetryPolicy DagRetryPolicy
  	TimeoutSec  int
  	Output      string
  	Tags        []string
  }

  // DagHandlers contains lifecycle handler steps.
  type DagHandlers struct {
  	Init    []DagStep
  	Success []DagStep
  	Failure []DagStep
  	Exit    []DagStep
  }

  // DagDefinition is a complete DAG workflow parsed from YAML.
  type DagDefinition struct {
  	Name        string
  	Description string
  	Group       string
  	Type        string // "graph" or "" (chain/sequential)
  	WorkingDir  string
  	Shell       string
  	Env         map[string]string
  	Schedule    []string
  	Steps       []DagStep
  	HandlerOn   DagHandlers
  	Tags        []string
  	TimeoutSec  int
  	FilePath    string
  }

  // DagValidationResult contains validation output.
  type DagValidationResult struct {
  	Valid    bool
  	Errors   []string
  	Warnings []string
  	Levels   [][]string // topological levels (step names/IDs)
  }


id: dag_retry_policy_go_core
name: dag_retry_policy
lang: go
domain: core
version: 1.0.0
algebraic: product
definition:
  type DagRetryPolicy struct {
  Limit int
  IntervalSec int
  }

tags: ["dag","workflow","retry","policy"]
uses_types: []
file_path: functions/core/dag_definition.go
created_at: 2026-04-12T11:03:11Z
updated_at: 2026-04-12T11:03:11Z
content_hash: 4a8c40235dc9f1818919b55954c36c7a93e0d55fbcb6af962867869f08afd718
description:
  Politica de reintentos automaticos para un step DAG. Limit es el numero maximo de reintentos e IntervalSec es el tiempo de espera entre intentos en segundos.

notes:
  Tipo producto. Embebido en DagStep. Corresponde al campo `retry_policy` del YAML de Dagu. Limit=0 significa sin reintentos.

code:
[cell repeats an earlier one in this file: 1438 characters]


id: dag_step_go_core
name: dag_step
lang: go
domain: core
version: 1.0.0
algebraic: product
tags: ["dag","workflow","step","yaml","dagu"]
uses_types: ["dag_continue_on_go_core","dag_retry_policy_go_core"]
file_path: functions/core/dag_definition.go
created_at: 2026-04-12T11:03:11Z
updated_at: 2026-04-12T11:03:11Z
content_hash: fe54b28a53af7b45d9606c4e64c7dc7edfd700d6a110a53201d17a09b0a70875
definition:
  type DagStep struct {
      Name        string
      ID          string
      Description string
      Command     string
      Script      string
      Args        []string
      Shell       string
      Dir         string
      Depends     []string
      Env         map[string]string
      ContinueOn  DagContinueOn
      RetryPolicy DagRetryPolicy
      TimeoutSec  int
      Output      string
      Tags        []string
  }

description:
  Un paso individual en un workflow DAG con command/script, dependencias y configuracion de reintentos. Soporta variables de entorno por step, directorio de trabajo propio y politica continue_on.

notes:
  Tipo producto. El campo ID es opcional y se usa como referencia en otros steps (ej: `${id.stdout}`). Si Name e ID estan ambos vacios, el step es invalido. Env del step se mergea sobre el Env del DAG padre durante la resolucion.

code:
[cell repeats an earlier one in this file: 1438 characters]


id: dag_validation_result_go_core
name: dag_validation_result
lang: go
domain: core
version: 1.0.0
algebraic: product
tags: ["dag","validation","workflow","result"]
uses_types: []
file_path: functions/core/dag_definition.go
created_at: 2026-04-12T11:03:11Z
updated_at: 2026-04-12T11:03:11Z
content_hash: f76918b2d9229b43f89075362fda85d2345c87dd88269e5009d4b8af9c86c984
definition:
  type DagValidationResult struct {
      Valid    bool
      Errors   []string
      Warnings []string
      Levels   [][]string
  }

description:
  Resultado de validar un DagDefinition. Contiene errores estructurales (ciclos, depends invalidos, nombres duplicados), warnings (command+script simultaneos) y los niveles topologicos calculados si el DAG es valido.

notes:
  Tipo producto. Levels solo se rellena cuando Valid=true. Cada sub-slice de Levels contiene los nombres/IDs de steps que pueden ejecutarse en paralelo. El orden de Levels refleja el orden topologico del grafo de dependencias.

code:
[cell repeats an earlier one in this file: 1438 characters]


id: document_go_core
name: document
lang: go
domain: core
version: 1.0.0
algebraic: product
description: Documento o archivo de interes. Contiene titulo, formato, clasificacion y hash.
tags: ["document","file","entity"]
uses_types: []
file_path: functions/core/document.go
created_at: 2026-04-06T14:00:53Z
updated_at: 2026-04-06T14:00:53Z
content_hash: 83ddb66aada2c8451a584a0727bb54a8670c0bd9c3163b42d1044e14e28fa6ae
definition:
  type Document struct {
      Title          string `json:"title"`
      Format         string `json:"format"`
      Classification string `json:"classification"`
      HashSHA256     string `json:"hash_sha256"`
      Source         string `json:"source"`
  }

code:
  package core

  // Document represents a document or file of interest.
  type Document struct {
  	Title          string `json:"title"`
  	Format         string `json:"format"`
  	Classification string `json:"classification"`
  	HashSHA256     string `json:"hash_sha256"`
  	Source         string `json:"source"`
  }


id: domain_go_core
name: domain
lang: go
domain: core
version: 1.0.0
algebraic: product
description: Dominio de internet. Contiene FQDN, registrar y nameservers.
tags: ["domain","dns","infrastructure","entity"]
uses_types: []
file_path: functions/core/domain.go
created_at: 2026-04-06T14:00:53Z
updated_at: 2026-04-06T14:00:53Z
content_hash: b5419e889cd5905e74179148fff13a941f762a87e9bded90c3d4860a3a076874
definition:
  type Domain struct {
      FQDN        string   `json:"fqdn"`
      Registrar   string   `json:"registrar"`
      CreatedDate string   `json:"created_date"`
      ExpiresDate string   `json:"expires_date"`
      NameServers []string `json:"name_servers"`
  }

code:
  package core

  // Domain represents an internet domain.
  type Domain struct {
  	FQDN        string   `json:"fqdn"`
  	Registrar   string   `json:"registrar"`
  	CreatedDate string   `json:"created_date"`
  	ExpiresDate string   `json:"expires_date"`
  	NameServers []string `json:"name_servers"`
  }


id: email_go_core
name: email
lang: go
domain: core
version: 1.0.0
algebraic: product
description: Direccion de email de interes. Indica si fue verificada y si aparece en breaches.
tags: ["email","identity","entity"]
uses_types: []
file_path: functions/core/email.go
created_at: 2026-04-06T14:00:53Z
updated_at: 2026-04-06T14:00:53Z
content_hash: 2f4a644e7afe69074b7be26c496641147bb42bd4ba826ce14fe04cdf8a7efe8b
definition:
  type Email struct {
      Address  string `json:"address"`
      Provider string `json:"provider"`
      Verified bool   `json:"verified"`
      Breached bool   `json:"breached"`
  }

code:
  package core

  // Email represents an email address of interest.
  type Email struct {
  	Address  string `json:"address"`
  	Provider string `json:"provider"`
  	Verified bool   `json:"verified"`
  	Breached bool   `json:"breached"`
  }


id: error_go_core
name: error
lang: go
domain: core
version: 1.0.0
algebraic: product
definition:
  type Error struct {
  Message string
  }

description: Tipo de error base del registry. Referenciado como error_type por funciones impuras.
tags: ["error","base","impure"]
uses_types: []
file_path: functions/core/error.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 35f07b24eb07327f58dd249725d9e71766989f6e5884e9b1d7b0e09c9cee4a5b
code:
  package core

  // Error is the standard error type for impure functions in the registry.
  // Wraps a message string. Used as error_type reference.
  type Error struct {
  	Message string
  }

  func (e Error) Error() string {
  	return e.Message
  }


id: event_go_core
name: event
lang: go
domain: core
version: 1.0.0
algebraic: product
description: Evento o incidente notable. Contiene tipo, fecha, ubicacion y severidad.
tags: ["event","incident","entity"]
uses_types: []
file_path: functions/core/event.go
created_at: 2026-04-06T14:00:53Z
updated_at: 2026-04-06T14:00:53Z
content_hash: 3b9f49b3ed9d7bc7d217bb7ef5cf3ee123ce3004a6822533ef10f3bfa508ebc4
definition:
  type Event struct {
      EventType   string `json:"event_type"`
      Date        string `json:"date"`
      Location    string `json:"location"`
      Description string `json:"description"`
      Severity    string `json:"severity"`
  }

code:
  package core

  // Event represents a notable event or incident.
  type Event struct {
  	EventType   string `json:"event_type"`
  	Date        string `json:"date"`
  	Location    string `json:"location"`
  	Description string `json:"description"`
  	Severity    string `json:"severity"`
  }


id: location_go_core
name: location
lang: go
domain: core
version: 1.0.0
algebraic: product
description: Ubicacion geografica de interes. Contiene coordenadas, direccion y pais.
tags: ["location","geography","entity"]
uses_types: []
file_path: functions/core/location.go
created_at: 2026-04-06T14:00:53Z
updated_at: 2026-04-06T14:00:53Z
content_hash: 88e3dafd73cba55e94e29dfac3f5744cab778bdebd0fbc1a6a1a700d08d89b28
definition:
  type Location struct {
      Lat     float64 `json:"lat"`
      Lon     float64 `json:"lon"`
      Address string  `json:"address"`
      Country string  `json:"country"`
      City    string  `json:"city"`
  }

code:
  package core

  // Location represents a geographic location of interest.
  type Location struct {
  	Lat     float64 `json:"lat"`
  	Lon     float64 `json:"lon"`
  	Address string  `json:"address"`
  	Country string  `json:"country"`
  	City    string  `json:"city"`
  }


id: option_go_core
name: option
lang: go
domain: core
version: 1.0.0
algebraic: sum
definition:
  type Option[T any] struct {
  value *T
  }

tags: ["option","sum","nullable","functional","generic"]
uses_types: []
file_path: functions/core/option.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
notes: Tipo suma con dos variantes: Some(T) y None. Inspirado en Option de Rust.
content_hash: cf708c9b9e72b741d3d190942e2cd61c5eb9e27476424a26e97f22cbf08b4709
description:
  Tipo suma generico que representa un valor opcional: Some(T) o None. Alternativa a punteros nil para modelar ausencia de valor de forma explicita.

code:
  package core

  // Option represents a value that may or may not be present.
  // A sum type: Some(T) | None.
  type Option[T any] struct {
  	value *T
  }

  // Some creates an Option containing a value.
  func Some[T any](v T) Option[T] {
  	return Option[T]{value: &v}
  }

  // None creates an empty Option.
  func None[T any]() Option[T] {
  	return Option[T]{}
  }

  // IsSome returns true if the Option contains a value.
  func (o Option[T]) IsSome() bool {
  	return o.value != nil
  }

  // IsNone returns true if the Option is empty.
  func (o Option[T]) IsNone() bool {
  	return o.value == nil
  }

  // Unwrap returns the value or panics if None.
  func (o Option[T]) Unwrap() T {
  	if o.value == nil {
  		panic("called Unwrap on None")
  	}
  	return *o.value
  }

  // UnwrapOr returns the value or a default if None.
  func (o Option[T]) UnwrapOr(def T) T {
  	if o.value != nil {
  		return *o.value
  	}
  	return def
  }


id: organization_go_core
name: organization
lang: go
domain: core
version: 1.0.0
algebraic: product
description: Organizacion o empresa de interes. Contiene datos legales, sector y score de riesgo.
tags: ["organization","company","entity"]
uses_types: []
file_path: functions/core/organization.go
created_at: 2026-04-06T14:00:53Z
updated_at: 2026-04-06T14:00:53Z
content_hash: 8536fd99c1a9940f121928d5d423ca4c6bab9704db8c0ae29a3855c3558b91fe
definition:
  type Organization struct {
      LegalName string  `json:"legal_name"`
      Country   string  `json:"country"`
      Sector    string  `json:"sector"`
      Founded   string  `json:"founded"`
      RiskScore float64 `json:"risk_score"`
  }

code:
  package core

  // Organization represents a company or group of interest.
  type Organization struct {
  	LegalName string  `json:"legal_name"`
  	Country   string  `json:"country"`
  	Sector    string  `json:"sector"`
  	Founded   string  `json:"founded"`
  	RiskScore float64 `json:"risk_score"`
  }


id: pair_go_core
name: pair
lang: go
domain: core
version: 1.0.0
algebraic: product
definition:
  type Pair[A any, B any] struct {
  First A
  Second B
  }

tags: ["pair","tuple","product","generic"]
uses_types: []
file_path: functions/core/pair.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 28511cc15cf5db62e6c5de797da42f3fe4db9068ee1ed531f90016d2c5f74397
description:
  Tipo producto generico que agrupa dos valores de tipos potencialmente distintos. Util para ZipSlices y operaciones que devuelven dos resultados.

code:
  package core

  // Pair holds two values of potentially different types.
  type Pair[A any, B any] struct {
  	First  A
  	Second B
  }


id: person_go_core
name: person
lang: go
domain: core
version: 1.0.0
algebraic: product
description: Persona de interes. Contiene identidad, nacionalidad y score de riesgo.
tags: ["person","identity","entity"]
uses_types: []
file_path: functions/core/person.go
created_at: 2026-04-06T14:00:53Z
updated_at: 2026-04-06T14:00:53Z
content_hash: 774c47197c07e37b578b9e8a30e189359dff57f6eea5f6510462d7d5807943b0
definition:
  type Person struct {
      FullName    string   `json:"full_name"`
      Alias       []string `json:"alias"`
      Nationality string   `json:"nationality"`
      DOB         string   `json:"dob"`
      Gender      string   `json:"gender"`
      RiskScore   float64  `json:"risk_score"`
  }

code:
  package core

  // Person represents an individual of interest.
  type Person struct {
  	FullName    string   `json:"full_name"`
  	Alias       []string `json:"alias"`
  	Nationality string   `json:"nationality"`
  	DOB         string   `json:"dob"`
  	Gender      string   `json:"gender"`
  	RiskScore   float64  `json:"risk_score"`
  }


id: phone_go_core
name: phone
lang: go
domain: core
version: 1.0.0
algebraic: product
description: Numero de telefono de interes. Contiene carrier, codigo de pais y tipo.
tags: ["phone","telecom","identity","entity"]
uses_types: []
file_path: functions/core/phone.go
created_at: 2026-04-06T14:00:53Z
updated_at: 2026-04-06T14:00:53Z
content_hash: c6eab8641cccc086bedd03f11b188815dbb9a1afcc5bb4086c76526f09fcce42
definition:
  type Phone struct {
      Number      string `json:"number"`
      CountryCode string `json:"country_code"`
      Carrier     string `json:"carrier"`
      PhoneType   string `json:"phone_type"`
  }

code:
  package core

  // Phone represents a phone number of interest.
  type Phone struct {
  	Number      string `json:"number"`
  	CountryCode string `json:"country_code"`
  	Carrier     string `json:"carrier"`
  	PhoneType   string `json:"phone_type"`
  }


id: result_go_core
name: result
lang: go
domain: core
version: 1.0.0
algebraic: sum
definition:
  type Result[T any] struct {
  ok *T
  err error
  }

tags: ["result","sum","error-handling","functional","generic"]
uses_types: []
file_path: functions/core/result.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 9e9747db117d656a63baa6b98c49e4327e5ed40d1a85fc681840ce473a5bab6d
description:
  Tipo suma generico que representa exito (Ok) o fallo (Err). Permite componer operaciones que pueden fallar sin recurrir a multiples returns (T, error).

notes:
  Tipo suma con dos variantes: Ok(T) y Err(error). Inspirado en Result de Rust.
  Util para encadenar operaciones y evitar el patron `if err != nil` repetitivo.

code:
  package core

  // Result is a sum type representing either a successful value or an error.
  // Exactly one of Ok or Err is non-nil at any time.
  type Result[T any] struct {
  	ok  *T
  	err error
  }

  // Ok creates a successful Result.
  func Ok[T any](v T) Result[T] {
  	return Result[T]{ok: &v}
  }

  // Err creates a failed Result.
  func Err[T any](err error) Result[T] {
  	return Result[T]{err: err}
  }

  // IsOk returns true if the Result contains a value.
  func (r Result[T]) IsOk() bool {
  	return r.ok != nil
  }

  // IsErr returns true if the Result contains an error.
  func (r Result[T]) IsErr() bool {
  	return r.err != nil
  }

  // Unwrap returns the value or panics if the Result is an error.
  func (r Result[T]) Unwrap() T {
  	if r.ok == nil {
  		panic("called Unwrap on an Err Result")
  	}
  	return *r.ok
  }

  // UnwrapErr returns the error or panics if the Result is Ok.
  func (r Result[T]) UnwrapErr() error {
  	if r.err == nil {
  		panic("called UnwrapErr on an Ok Result")
  	}
  	return r.err
  }

  // UnwrapOr returns the value or a default if the Result is an error.
  func (r Result[T]) UnwrapOr(def T) T {
  	if r.ok != nil {
  		return *r.ok
  	}
  	return def
  }


id: social_media_go_core
name: social_media
lang: go
domain: core
version: 1.0.0
algebraic: product
description: Cuenta de red social de interes. Contiene plataforma, usuario y seguidores.
tags: ["social_media","identity","entity"]
uses_types: []
file_path: functions/core/social_media.go
created_at: 2026-04-06T14:00:53Z
updated_at: 2026-04-06T14:00:53Z
content_hash: df41c70eef05ff8a8623259f0fb55b883f2284665b7673013732e4c14cc7cd0a
definition:
  type SocialMedia struct {
      Platform  string `json:"platform"`
      Username  string `json:"username"`
      URL       string `json:"url"`
      Followers int    `json:"followers"`
      Verified  bool   `json:"verified"`
  }

code:
  package core

  // SocialMedia represents a social media account of interest.
  type SocialMedia struct {
  	Platform  string `json:"platform"`
  	Username  string `json:"username"`
  	URL       string `json:"url"`
  	Followers int    `json:"followers"`
  	Verified  bool   `json:"verified"`
  }


id: test_db_go_core
name: test_db
lang: go
domain: core
version: 1.0.0
algebraic: product
definition:
  type TestDB struct {
  DB *sql.DB
  Path string
  Cleanup func()
  }

tags: ["testing","database","sqlite","test_helper"]
uses_types: []
file_path: functions/core/test_db.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: 478509524565e687271bdf9cee7d8bd4b5b5d7cfb84f57a8e37d379e69418fd5
description:
  Base de datos SQLite de test con puntero al DB abierto, path (vacio para :memory:) y funcion de cleanup para cerrar conexiones al finalizar el test.

notes:
  Tipo producto — todos los campos siempre presentes. Retornado por `test_db_setup_go_core`.
  Path es `:memory:` para bases de datos en memoria.
  Cleanup se registra automaticamente en t.Cleanup() al crear el DB.

code:
  package core

  import (
  	"database/sql"
  )

  // TestDB encapsula una base de datos SQLite de test con su path y cleanup.
  type TestDB struct {
  	DB      *sql.DB
  	Path    string
  	Cleanup func()
  }


id: test_server_go_core
name: test_server
lang: go
domain: core
version: 1.0.0
algebraic: product
definition:
  type TestServer struct {
  URL string
  Client *http.Client
  Cleanup func()
  }

tags: ["testing","http","server","test_helper"]
uses_types: []
file_path: functions/core/test_server.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: d4b96905efeb2e66e68f3944fcaf67a2b7677c7fa5e19b8c1ecb0930ba49299b
description:
  Servidor HTTP de test con URL base, cliente preconfigurado y funcion de cleanup para liberar recursos al finalizar el test.

notes:
  Tipo producto — todos los campos siempre presentes. Retornado por `test_http_server_go_core`.
  Cleanup se registra automaticamente en t.Cleanup() al crear el servidor.

code:
  package core

  import (
  	"net/http"
  )

  // TestServer encapsula un servidor HTTP de test con su cliente y cleanup.
  type TestServer struct {
  	URL     string
  	Client  *http.Client
  	Cleanup func()
  }


id: cidr_block_go_cybersecurity
name: cidr_block
lang: go
domain: cybersecurity
version: 1.0.0
algebraic: product
definition:
  type CIDRBlock struct {
  Network *net.IPNet
  Broadcast net.IP
  Hosts int
  }

description: Rango de red CIDR parseado con network, broadcast y numero de hosts.
tags: ["cybersecurity","network","cidr","ip"]
uses_types: []
file_path: functions/cybersecurity/cidr_block.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 79c4dce13e6f3a58953613cea7d216eb25dc8d001e6f40be8b143eb550df17e2
code:
  package cybersecurity

  import "net"

  // CIDRBlock represents a parsed CIDR network range.
  type CIDRBlock struct {
  	Network   *net.IPNet
  	Broadcast net.IP
  	Hosts     int
  }


id: crypto_wallet_go_cybersecurity
name: crypto_wallet
lang: go
domain: cybersecurity
version: 1.0.0
algebraic: product
tags: ["osint","crypto","wallet","blockchain","intelligence"]
uses_types: []
file_path: functions/cybersecurity/crypto_wallet.go
created_at: 2026-04-06T14:00:53Z
updated_at: 2026-04-06T14:00:53Z
content_hash: a69a5b010f8ed13fde370bde992537e94b611df5d6648ed384a3ccd424174639
definition:
  type CryptoWallet struct {
      Address    string  `json:"address"`
      Blockchain string  `json:"blockchain"`
      Balance    float64 `json:"balance"`
      FirstSeen  string  `json:"first_seen"`
      LastSeen   string  `json:"last_seen"`
  }

description:
  Wallet de criptomonedas rastreada en una investigacion OSINT. Contiene direccion, blockchain, balance y timestamps.

code:
  package cybersecurity

  // CryptoWallet represents a cryptocurrency wallet tracked in an OSINT investigation.
  type CryptoWallet struct {
  	Address    string  `json:"address"`
  	Blockchain string  `json:"blockchain"`
  	Balance    float64 `json:"balance"`
  	FirstSeen  string  `json:"first_seen"`
  	LastSeen   string  `json:"last_seen"`
  }


id: ip_address_go_cybersecurity
name: ip_address
lang: go
domain: cybersecurity
version: 1.0.0
algebraic: product
description: Direccion IP de interes en una investigacion OSINT. Contiene ASN, geolocalizacion e ISP.
tags: ["osint","ip","network","infrastructure","intelligence"]
uses_types: []
file_path: functions/cybersecurity/ip_address.go
created_at: 2026-04-06T14:00:53Z
updated_at: 2026-04-06T14:00:53Z
content_hash: 30b90c8b8336830223af639b1d0838bf7f456e7fd3f1a1e8a445c7c381a2cc81
definition:
  type IPAddress struct {
      IP          string `json:"ip"`
      ASN         string `json:"asn"`
      Country     string `json:"country"`
      ISP         string `json:"isp"`
      Geolocation string `json:"geolocation"`
      LastSeen    string `json:"last_seen"`
  }

code:
  package cybersecurity

  // IPAddress represents an IP address of interest in an OSINT investigation.
  type IPAddress struct {
  	IP          string `json:"ip"`
  	ASN         string `json:"asn"`
  	Country     string `json:"country"`
  	ISP         string `json:"isp"`
  	Geolocation string `json:"geolocation"`
  	LastSeen    string `json:"last_seen"`
  }


id: malware_go_cybersecurity
name: malware
lang: go
domain: cybersecurity
version: 1.0.0
algebraic: product
tags: ["osint","malware","threat","intelligence"]
uses_types: []
file_path: functions/cybersecurity/malware.go
created_at: 2026-04-06T14:00:53Z
updated_at: 2026-04-06T14:00:53Z
content_hash: a0f13f6c6aa641d164c620eb8b21c9ec21894e10e59ded893a9b248844d858a8
definition:
  type Malware struct {
      Family      string `json:"family"`
      HashSHA256  string `json:"hash_sha256"`
      FirstSeen   string `json:"first_seen"`
      LastSeen    string `json:"last_seen"`
      ThreatLevel string `json:"threat_level"`
  }

description:
  Muestra de malware rastreada en una investigacion OSINT. Contiene familia, hash SHA256 y nivel de amenaza.

code:
  package cybersecurity

  // Malware represents a malware sample tracked in an OSINT investigation.
  type Malware struct {
  	Family      string `json:"family"`
  	HashSHA256  string `json:"hash_sha256"`
  	FirstSeen   string `json:"first_seen"`
  	LastSeen    string `json:"last_seen"`
  	ThreatLevel string `json:"threat_level"`
  }


id: port_result_go_cybersecurity
name: port_result
lang: go
domain: cybersecurity
version: 1.0.0
algebraic: sum
description: Tipo suma para resultados de escaneo TCP: Open (con banner), Closed o Filtered.
tags: ["cybersecurity","port","scan","network"]
uses_types: []
file_path: functions/cybersecurity/port_result.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 78347b0034562ba4e48877f6e2707fc26beb1f592ba1ab6f5ee4fb568164ca72
definition:
  type PortResult interface{ portResult() }
  type PortOpen struct{ Port int; Banner string }
  type PortClosed struct{ Port int }
  type PortFiltered struct{ Port int }

code:
  package cybersecurity

  // PortResult is a sum type for TCP port scan results.
  type PortResult interface{ portResult() }

  // PortOpen indicates the port accepted a connection.
  type PortOpen struct {
  	Port    int
  	Banner  string
  }

  // PortClosed indicates the port refused the connection.
  type PortClosed struct {
  	Port int
  }

  // PortFiltered indicates the port did not respond (timeout).
  type PortFiltered struct {
  	Port int
  }

  func (PortOpen) portResult()     {}
  func (PortClosed) portResult()   {}
  func (PortFiltered) portResult() {}


id: threat_result_go_cybersecurity
name: threat_result
lang: go
domain: cybersecurity
version: 1.0.0
algebraic: sum
description: Tipo suma para resultados de deteccion de amenazas: Clean, Suspicious o Malicious.
tags: ["cybersecurity","threat","detection","sqli"]
uses_types: []
file_path: functions/cybersecurity/threat_result.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: f0ab76e178120bd198fafbbcae88242d5ebb931c08313f58e24aa20d7707c97e
definition:
  type ThreatResult interface{ threatResult() }
  type Clean struct{}
  type Suspicious struct{ Reason string }
  type Malicious struct{ Pattern string }

code:
  package cybersecurity

  // ThreatResult is a sum type for SQL injection detection results.
  type ThreatResult interface{ threatResult() }

  // Clean indicates no threat was detected.
  type Clean struct{}

  // Suspicious indicates a possible threat with a reason.
  type Suspicious struct {
  	Reason string
  }

  // Malicious indicates a confirmed threat pattern.
  type Malicious struct {
  	Pattern string
  }

  func (Clean) threatResult()      {}
  func (Suspicious) threatResult() {}
  func (Malicious) threatResult()  {}


id: vulnerability_go_cybersecurity
name: vulnerability
lang: go
domain: cybersecurity
version: 1.0.0
algebraic: product
tags: ["osint","vulnerability","cve","threat","intelligence"]
uses_types: []
file_path: functions/cybersecurity/vulnerability.go
created_at: 2026-04-06T14:00:53Z
updated_at: 2026-04-06T14:00:53Z
content_hash: b4042546e36b2434b81783d40637aef0b1c588aa5d3fbba204618b24eef1b36b
definition:
  type Vulnerability struct {
      CVEID           string  `json:"cve_id"`
      CVSS            float64 `json:"cvss"`
      AffectedProduct string  `json:"affected_product"`
      Published       string  `json:"published"`
      Exploited       bool    `json:"exploited"`
  }

description:
  Vulnerabilidad CVE rastreada en una investigacion OSINT. Contiene CVSS score y estado de explotacion.

code:
  package cybersecurity

  // Vulnerability represents a CVE or security vulnerability in an OSINT investigation.
  type Vulnerability struct {
  	CVEID           string  `json:"cve_id"`
  	CVSS            float64 `json:"cvss"`
  	AffectedProduct string  `json:"affected_product"`
  	Published       string  `json:"published"`
  	Exploited       bool    `json:"exploited"`
  }


id: outlier_result_go_datascience
name: outlier_result
lang: go
domain: datascience
version: 1.0.0
algebraic: sum
description: Tipo suma que clasifica un dato como Normal o Outlier con su z-score. Usado por DetectOutliers.
tags: ["datascience","outlier","anomaly","statistics"]
uses_types: []
file_path: functions/datascience/outlier_result.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 95ec4fb4e68c172281b3d94819fe2ab30987d7c935076d4569abfb5fa7c19b3c
definition:
  type OutlierResult interface{ outlierResult() }
  type Normal struct { Index int; Value float64 }
  type Outlier struct { Index int; Value float64; ZScore float64 }

code:
  package datascience

  // OutlierResult is a sum type representing whether a data point is normal or an outlier.
  type OutlierResult interface{ outlierResult() }

  // Normal indicates the data point is within expected range.
  type Normal struct {
  	Index int
  	Value float64
  }

  // Outlier indicates the data point is anomalous.
  type Outlier struct {
  	Index  int
  	Value  float64
  	ZScore float64
  }

  func (Normal) outlierResult()  {}
  func (Outlier) outlierResult() {}


id: compose_project_go_docker
name: compose_project
lang: go
domain: docker
version: 1.0.0
algebraic: product
description: Proyecto Docker Compose con nombre, archivos de configuracion y lista de servicios.
tags: ["docker","compose","infra","orchestration"]
uses_types: []
file_path: types/docker/compose_project.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: bca8ad203ab561adab6abe81c0dbcae29dc8817ff8bd8f060b5cd6062212d8a4
definition:
  type ComposeProject struct {
      Name        string
      ConfigFiles string
      Services    []string
  }

code:
  package docker

  // ComposeProject representa un proyecto Docker Compose con sus servicios.
  type ComposeProject struct {
  	Name        string
  	ConfigFiles string
  	Services    []string
  }


id: container_go_docker
name: container
lang: go
domain: docker
version: 1.0.0
algebraic: product
description: Contenedor Docker con ID, nombre, imagen, estado y puertos expuestos.
tags: ["docker","container","infra"]
uses_types: []
file_path: types/docker/container.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 7c83601bcb310a82b353dab1537f919ed2ec62aa05d84958378f1f7e4f9423b2
definition:
  type Container struct {
      ID     string
      Names  string
      Image  string
      Status string
      State  string
      Ports  string
  }

code:
  package docker

  // Container representa un contenedor Docker con su estado y configuracion.
  type Container struct {
  	ID     string
  	Names  string
  	Image  string
  	Status string
  	State  string
  	Ports  string
  }


id: image_go_docker
name: image
lang: go
domain: docker
version: 1.0.0
algebraic: product
description: Imagen Docker con repositorio, tag, tamaño y fecha de creacion.
tags: ["docker","image","infra"]
uses_types: []
file_path: types/docker/image.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 0dd4502536f4401923af81b0d07a218af3bc123eae4107d272dc450fec1c8729
definition:
  type Image struct {
      ID         string
      Repository string
      Tag        string
      Size       string
      CreatedAt  string
  }

code:
  package docker

  // Image representa una imagen Docker con su repositorio, tag y tamaño.
  type Image struct {
  	ID         string
  	Repository string
  	Tag        string
  	Size       string
  	CreatedAt  string
  }


id: network_go_docker
name: network
lang: go
domain: docker
version: 1.0.0
algebraic: product
definition:
  type Network struct {
  ID string
  Name string
  Driver string
  Scope string
  }

description: Red Docker con nombre, driver y scope (local/global).
tags: ["docker","network","infra"]
uses_types: []
file_path: types/docker/network.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 6e2a83a5a18a201295ee27d0b112560e7179a7ccb13e5a1119a26295aaac8f5f
code:
  package docker

  // Network representa una red Docker con nombre, driver y scope.
  type Network struct {
  	ID     string
  	Name   string
  	Driver string
  	Scope  string
  }


id: volume_go_docker
name: volume
lang: go
domain: docker
version: 1.0.0
algebraic: product
definition:
  type Volume struct {
  Name string
  Driver string
  Mountpoint string
  }

description: Volumen Docker con nombre, driver y punto de montaje en el host.
tags: ["docker","volume","storage","infra"]
uses_types: []
file_path: types/docker/volume.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: b7554610ffb25e6fbc74f544d8e83ceb3d59fb5237009215069d5447900b9933
code:
  package docker

  // Volume representa un volumen Docker con nombre, driver y punto de montaje.
  type Volume struct {
  	Name       string
  	Driver     string
  	Mountpoint string
  }


id: bollinger_result_go_finance
name: bollinger_result
lang: go
domain: finance
version: 1.0.0
algebraic: product
definition:
  type BollingerResult struct {
  Upper []float64
  Middle []float64
  Lower []float64
  }

description: Resultado de Bollinger Bands con bandas superior, media e inferior.
tags: ["finance","bollinger","indicator","bands"]
uses_types: []
file_path: functions/finance/bollinger_result.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 7182d51a07eaff448efcdc618e9aac2ae31c41da279536739832c8a0f9a112a1
code:
  package finance

  // BollingerResult holds the three bands of a Bollinger Bands calculation.
  type BollingerResult struct {
  	Upper  []float64
  	Middle []float64
  	Lower  []float64
  }


id: drawdown_result_go_finance
name: drawdown_result
lang: go
domain: finance
version: 1.0.0
algebraic: product
definition:
  type DrawdownResult struct {
  Value float64
  Start int
  End int
  }

description: Resultado de maximo drawdown con el valor de caida y los indices de inicio y fin.
tags: ["finance","drawdown","risk","metric"]
uses_types: []
file_path: functions/finance/drawdown_result.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 0c3aba3c519ec64a7efdb61585caa4ed1c36d800a38b36ba8616f829a9e16a5d
code:
  package finance

  // DrawdownResult holds the maximum drawdown value and the indices where it occurred.
  type DrawdownResult struct {
  	Value float64
  	Start int
  	End   int
  }


id: ohlcv_go_finance
name: ohlcv
lang: go
domain: finance
version: 1.0.0
algebraic: product
description: Vela de mercado con precios de apertura, maximo, minimo, cierre y volumen.
tags: ["finance","market","candle","ohlcv"]
uses_types: []
file_path: functions/finance/ohlcv.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 61fa8cb8c4fec6ca207a7e6b4b822fe5a8149bf656fcab65c46aa11db54af098
definition:
  type OHLCV struct {
      Open   float64
      High   float64
      Low    float64
      Close  float64
      Volume float64
  }

code:
  package finance

  // OHLCV represents a market candle with open, high, low, close prices and volume.
  type OHLCV struct {
  	Open   float64
  	High   float64
  	Low    float64
  	Close  float64
  	Volume float64
  }


id: tick_go_finance
name: tick
lang: go
domain: finance
version: 1.0.0
algebraic: product
description: Evento de trade individual en un mercado. Contiene simbolo, precio, volumen y timestamp.
tags: ["finance","market","tick","trade"]
uses_types: []
file_path: functions/finance/tick.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: a1661b780e727224d5bd8ec59a73804f94578ecd85f0efb06869e85fbbb53116
definition:
  type Tick struct {
      Symbol    string
      Price     float64
      Volume    float64
      Timestamp time.Time
  }

code:
  package finance

  import "time"

  // Tick represents a single trade event in a market.
  type Tick struct {
  	Symbol    string
  	Price     float64
  	Volume    float64
  	Timestamp time.Time
  }


id: config_error_go_infra
name: config_error
lang: go
domain: infra
version: 1.0.0
algebraic: product
definition:
  type ConfigError struct {
  Field string
  Message string
  Tag string
  }

tags: ["config","validation","error","infra"]
uses_types: []
file_path: functions/infra/config_error.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: 97fa1d2a27f17f755f69a4c9b7ff94363651b08c7a3dcf5e0aaba8ec68c366ed
description:
  Error de validacion de un campo de configuracion. Field identifica el campo, Message describe el problema, Tag indica la regla que fallo (required, format, etc.).

notes:
  Tipo producto — todos los campos siempre presentes. Se acumula en ConfigValidation.Errors. Tag toma valores como "required" o "format" para que el consumidor pueda filtrar por tipo de error.

code:
  package infra

  // ConfigError represents a single validation error for a configuration field.
  type ConfigError struct {
  	Field   string // struct field name or env var name
  	Message string // human-readable error description
  	Tag     string // validation tag that triggered the error (e.g. "required", "format")
  }


id: config_validation_go_infra
name: config_validation
lang: go
domain: infra
version: 1.0.0
algebraic: product
definition:
  type ConfigValidation struct {
  Errors []ConfigError
  IsValid bool
  }

tags: ["config","validation","result","infra"]
uses_types: ["config_error_go_infra"]
file_path: functions/infra/config_validation.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: 234ad1b71e0d2c1e194d96aada5ad07e9e5e029ce0d1684676c6fdb65b006426
description:
  Resultado de validar una struct de configuracion. Contiene todos los errores acumulados y un bool de conveniencia. IsValid es true solo cuando Errors esta vacio.

notes:
  Tipo producto. IsValid se computa derivadamente de len(Errors)==0. Permite al consumidor tanto iterar sobre todos los errores como hacer un check rapido con IsValid.

code:
  package infra

  // ConfigValidation holds the result of validating a configuration struct.
  type ConfigValidation struct {
  	Errors  []ConfigError // all validation errors found; empty if valid
  	IsValid bool          // true if Errors is empty
  }


id: container_info_go_infra
name: container_info
lang: go
domain: infra
version: 1.0.0
algebraic: product
description: Información básica de un contenedor Docker: ID, nombre, imagen, estado, puertos, labels.
tags: ["docker","container","infra"]
uses_types: []
file_path: functions/infra/container_info.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 1b88ea49550e8787b7fcd0b8ee6130fef5bd776dd8f42bca53e9c24ed3c6634c
definition:
  type ContainerInfo struct {
    ID      string
    Name    string
    Image   string
    Status  string
    State   string
    Ports   string
    Created string
    Labels  map[string]string
  }

examples:
  ```go
  c := ContainerInfo{
      ID:     "abc123",
      Name:   "my-app",
      Image:  "nginx:latest",
      Status: "Up 2 hours",
      State:  "running",
      Ports:  "0.0.0.0:8080->80/tcp",
  }
  ```

notes:
  Tipo product que modela la salida de `docker ps`. Los campos mapean directamente a las columnas del formato JSON de Docker.

code:
  package infra

  // ContainerInfo representa la información básica de un contenedor Docker.
  type ContainerInfo struct {
  	ID      string            // ID corto del contenedor
  	Name    string            // Nombre del contenedor
  	Image   string            // Imagen usada
  	Status  string            // Estado actual (running, exited, etc.)
  	State   string            // Estado detallado
  	Ports   string            // Mapeo de puertos
  	Created string            // Fecha de creación
  	Labels  map[string]string // Labels del contenedor
  }


id: CRUDField_go_infra
name: CRUDField
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["crud","field","sqlite","validation","rest","infra"]
uses_types: []
file_path: functions/infra/crud_resource.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
content_hash: e7a63f1278225c3bcc283664938365aa169ae66f568e1a5715125bef02d84fe5
definition:
  type CRUDField struct {
      Name        string
      Type        string
      Required    bool
      Unique      bool
      Default     string
      Validations map[string]string
  }

description:
  Define un campo de un recurso CRUD con su tipo SQLite, constraints y validaciones. Se agrega al slice Fields de CRUDResource.

examples:
  ```go
  field := CRUDField{
      Name:     "name",
      Type:     "TEXT",
      Required: true,
      Unique:   true,
      Validations: map[string]string{
          "min_length": "1",
          "max_length": "100",
      },
  }
  ```

notes:
  Tipo producto. Type debe ser uno de: TEXT, INTEGER, REAL, BLOB. Default es SQL literal (por ejemplo "'active'" o "0"). Validations es un mapa generico con claves: min_length, max_length, pattern (TEXT), min, max (INTEGER/REAL), enum ("a,b,c" para TEXT). Las validaciones se evaluan en Go antes de cada INSERT/UPDATE.

code:
  package infra

  // CRUDResource define un recurso CRUD completo para generar handlers HTTP.
  // Name es el nombre singular del recurso en snake_case (ej: "project").
  // Table es el nombre de la tabla SQLite asociada (ej: "projects").
  // Fields son las columnas del recurso sin contar id, created_at, updated_at y deleted_at.
  // SoftDelete si es true, el handler delete hace UPDATE deleted_at en vez de DELETE real.
  type CRUDResource struct {
  	Name       string      // nombre del recurso (singular, snake_case)
  	Table      string      // nombre de la tabla SQLite
  	Fields     []CRUDField // campos del recurso (sin id ni timestamps)
  	SoftDelete bool        // si true, usa deleted_at en vez de DELETE
  }

  // CRUDField define un campo de un recurso CRUD.
  // Type debe ser uno de: TEXT, INTEGER, REAL, BLOB.
  // Required fuerza NOT NULL en la tabla y validacion en create.
  // Unique anade un UNIQUE constraint en la tabla.
  // Default es el valor SQL por defecto (vacio = sin default).
  // Validations define reglas de validacion: min_length, max_length, pattern, min, max, enum.
  type CRUDField struct {
  	Name        string            // nombre del campo (snake_case)
  	Type        string            // tipo SQLite: TEXT, INTEGER, REAL, BLOB
  	Required    bool              // NOT NULL + validacion en create
  	Unique      bool              // UNIQUE constraint
  	Default     string            // valor por defecto en CREATE TABLE
  	Validations map[string]string // reglas: min_length, max_length, pattern, min, max, enum
  }

  // CRUDListParams agrupa los parametros de paginacion, orden y filtro del endpoint list.
  // Page es 1-based (default 1). PerPage tiene default 20 y max 100.
  // SortBy es el nombre del campo por el que ordenar. SortDir es "asc" o "desc".
  // Filters contiene pares campo -> valor para filtros exactos en WHERE.
  type CRUDListParams struct {
  	Page    int               // pagina actual (1-based, default 1)
  	PerPage int               // items por pagina (default 20, max 100)
  	SortBy  string            // campo por el que ordenar
  	SortDir string            // "asc" o "desc"
  	Filters map[string]string // campo -> valor para WHERE exacto
  }

  // CRUDListResult resultado paginado de una lista CRUD, serializable a JSON.
  type CRUDListResult struct {
  	Items      []map[string]any `json:"items"`
  	Total      int              `json:"total"`
  	Page       int              `json:"page"`
  	PerPage    int              `json:"per_page"`
  	TotalPages int              `json:"total_pages"`
  }


id: CRUDListParams_go_infra
name: CRUDListParams
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["crud","list","pagination","filter","sort","infra"]
uses_types: []
file_path: functions/infra/crud_resource.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
content_hash: 17fea57e845a2f33c6642bc341c13c9c3d1cd7c1612c7cfe3f5256e52e3ca72a
definition:
  type CRUDListParams struct {
      Page    int
      PerPage int
      SortBy  string
      SortDir string
      Filters map[string]string
  }

description:
  Parametros de paginacion, orden y filtrado de un endpoint list CRUD. Se extrae de los query params de la request HTTP.

examples:
  ```go
  params := CRUDListParams{
      Page:    1,
      PerPage: 20,
      SortBy:  "created_at",
      SortDir: "desc",
      Filters: map[string]string{"status": "active"},
  }
  ```

notes:
  Tipo producto. Page es 1-based con default 1. PerPage tiene default 20 y se satura a 100. SortDir solo acepta "asc" o "desc" (default "desc"). Filters usa igualdad exacta en WHERE — los campos se validan contra la definicion del recurso para evitar SQL injection.

code:
  package infra

  // CRUDResource define un recurso CRUD completo para generar handlers HTTP.
  // Name es el nombre singular del recurso en snake_case (ej: "project").
  // Table es el nombre de la tabla SQLite asociada (ej: "projects").
  // Fields son las columnas del recurso sin contar id, created_at, updated_at y deleted_at.
  // SoftDelete si es true, el handler delete hace UPDATE deleted_at en vez de DELETE real.
  type CRUDResource struct {
  	Name       string      // nombre del recurso (singular, snake_case)
  	Table      string      // nombre de la tabla SQLite
  	Fields     []CRUDField // campos del recurso (sin id ni timestamps)
  	SoftDelete bool        // si true, usa deleted_at en vez de DELETE
  }

  // CRUDField define un campo de un recurso CRUD.
  // Type debe ser uno de: TEXT, INTEGER, REAL, BLOB.
  // Required fuerza NOT NULL en la tabla y validacion en create.
  // Unique anade un UNIQUE constraint en la tabla.
  // Default es el valor SQL por defecto (vacio = sin default).
  // Validations define reglas de validacion: min_length, max_length, pattern, min, max, enum.
  type CRUDField struct {
  	Name        string            // nombre del campo (snake_case)
  	Type        string            // tipo SQLite: TEXT, INTEGER, REAL, BLOB
  	Required    bool              // NOT NULL + validacion en create
  	Unique      bool              // UNIQUE constraint
  	Default     string            // valor por defecto en CREATE TABLE
  	Validations map[string]string // reglas: min_length, max_length, pattern, min, max, enum
  }

  // CRUDListParams agrupa los parametros de paginacion, orden y filtro del endpoint list.
  // Page es 1-based (default 1). PerPage tiene default 20 y max 100.
  // SortBy es el nombre del campo por el que ordenar. SortDir es "asc" o "desc".
  // Filters contiene pares campo -> valor para filtros exactos en WHERE.
  type CRUDListParams struct {
  	Page    int               // pagina actual (1-based, default 1)
  	PerPage int               // items por pagina (default 20, max 100)
  	SortBy  string            // campo por el que ordenar
  	SortDir string            // "asc" o "desc"
  	Filters map[string]string // campo -> valor para WHERE exacto
  }

  // CRUDListResult resultado paginado de una lista CRUD, serializable a JSON.
  type CRUDListResult struct {
  	Items      []map[string]any `json:"items"`
  	Total      int              `json:"total"`
  	Page       int              `json:"page"`
  	PerPage    int              `json:"per_page"`
  	TotalPages int              `json:"total_pages"`
  }


id: CRUDListResult_go_infra
name: CRUDListResult
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["crud","list","pagination","result","infra"]
uses_types: []
file_path: functions/infra/crud_resource.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
content_hash: 1b0f74fca6393d31730219fac530343b71f290bf2c36e4ca198b43f9c8351b76
definition:
  type CRUDListResult struct {
      Items      []map[string]any `json:"items"`
      Total      int              `json:"total"`
      Page       int              `json:"page"`
      PerPage    int              `json:"per_page"`
      TotalPages int              `json:"total_pages"`
  }

description:
  Resultado paginado de un endpoint list CRUD. Incluye los items de la pagina y metadatos de paginacion para el cliente.

examples:
  ```go
  result := CRUDListResult{
      Items:      []map[string]any{{"id": "a", "name": "X"}},
      Total:      42,
      Page:       1,
      PerPage:    20,
      TotalPages: 3,
  }
  ```

notes:
  Tipo producto. Items es la lista de registros de la pagina actual serializados como map[string]any (schema dinamico). Total es el conteo global sin paginacion. TotalPages = ceil(Total / PerPage). Se serializa directamente como JSON con HTTPJSONResponse.

code:
  package infra

  // CRUDResource define un recurso CRUD completo para generar handlers HTTP.
  // Name es el nombre singular del recurso en snake_case (ej: "project").
  // Table es el nombre de la tabla SQLite asociada (ej: "projects").
  // Fields son las columnas del recurso sin contar id, created_at, updated_at y deleted_at.
  // SoftDelete si es true, el handler delete hace UPDATE deleted_at en vez de DELETE real.
  type CRUDResource struct {
  	Name       string      // nombre del recurso (singular, snake_case)
  	Table      string      // nombre de la tabla SQLite
  	Fields     []CRUDField // campos del recurso (sin id ni timestamps)
  	SoftDelete bool        // si true, usa deleted_at en vez de DELETE
  }

  // CRUDField define un campo de un recurso CRUD.
  // Type debe ser uno de: TEXT, INTEGER, REAL, BLOB.
  // Required fuerza NOT NULL en la tabla y validacion en create.
  // Unique anade un UNIQUE constraint en la tabla.
  // Default es el valor SQL por defecto (vacio = sin default).
  // Validations define reglas de validacion: min_length, max_length, pattern, min, max, enum.
  type CRUDField struct {
  	Name        string            // nombre del campo (snake_case)
  	Type        string            // tipo SQLite: TEXT, INTEGER, REAL, BLOB
  	Required    bool              // NOT NULL + validacion en create
  	Unique      bool              // UNIQUE constraint
  	Default     string            // valor por defecto en CREATE TABLE
  	Validations map[string]string // reglas: min_length, max_length, pattern, min, max, enum
  }

  // CRUDListParams agrupa los parametros de paginacion, orden y filtro del endpoint list.
  // Page es 1-based (default 1). PerPage tiene default 20 y max 100.
  // SortBy es el nombre del campo por el que ordenar. SortDir es "asc" o "desc".
  // Filters contiene pares campo -> valor para filtros exactos en WHERE.
  type CRUDListParams struct {
  	Page    int               // pagina actual (1-based, default 1)
  	PerPage int               // items por pagina (default 20, max 100)
  	SortBy  string            // campo por el que ordenar
  	SortDir string            // "asc" o "desc"
  	Filters map[string]string // campo -> valor para WHERE exacto
  }

  // CRUDListResult resultado paginado de una lista CRUD, serializable a JSON.
  type CRUDListResult struct {
  	Items      []map[string]any `json:"items"`
  	Total      int              `json:"total"`
  	Page       int              `json:"page"`
  	PerPage    int              `json:"per_page"`
  	TotalPages int              `json:"total_pages"`
  }


id: CRUDResource_go_infra
name: CRUDResource
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["crud","resource","http","sqlite","rest","infra"]
uses_types: ["CRUDField_go_infra"]
file_path: functions/infra/crud_resource.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
content_hash: 8b7dc04d296f8a295b5c792a88e4ffc77241ecdfc58c7ec39e542b4aa9f2fadc
definition:
  type CRUDResource struct {
      Name       string
      Table      string
      Fields     []CRUDField
      SoftDelete bool
  }

description:
  Define un recurso CRUD completo (nombre, tabla, campos y modo de borrado) para generar handlers HTTP y SQL DDL. Se usa como input de crud_generate_table_sql y crud_generate_handlers.

examples:
  ```go
  res := CRUDResource{
      Name:  "project",
      Table: "projects",
      Fields: []CRUDField{
          {Name: "name", Type: "TEXT", Required: true, Unique: true},
          {Name: "description", Type: "TEXT"},
      },
      SoftDelete: false,
  }
  ```

notes:
  Tipo producto. Name es el singular en snake_case ("project"), Table es el plural SQL ("projects"). Fields no incluye id ni timestamps: esos los gestiona el generador. Si SoftDelete es true, la tabla tendra una columna deleted_at TEXT y el handler delete hara UPDATE en vez de DELETE.

code:
[cell repeats an earlier one in this file: 2486 characters]


id: DagRun_go_infra
name: DagRun
lang: go
domain: infra
version: 1.0.0
algebraic: product
description: Representa una ejecucion de un workflow DAG. Almacenado en SQLite con estado, timestamps y trigger.
tags: ["dag","execution","run","workflow"]
uses_types: []
file_path: functions/infra/dag_run.go
created_at: 2026-04-12T11:03:11Z
updated_at: 2026-04-12T11:03:11Z
content_hash: f22d2ac83a2680b3a73c71e936d32bd4e89702cacd5862e117dd6ecac250b69b
definition:
  type DagRun struct {
      ID         string
      DagName    string
      DagPath    string
      Status     string
      StartedAt  time.Time
      FinishedAt time.Time
      Trigger    string
      Error      string
  }

notes:
  Status puede ser: pending, running, success, failed, cancelled.
  Trigger puede ser: manual, cron, api.

code:
  package infra

  import "time"

  // DagRun represents one execution of a DAG workflow.
  type DagRun struct {
  	ID         string
  	DagName    string
  	DagPath    string
  	Status     string // pending, running, success, failed, cancelled
  	StartedAt  time.Time
  	FinishedAt time.Time
  	Trigger    string // manual, cron, api
  	Error      string
  }

  // DagStepResult represents the outcome of one step within a DagRun.
  type DagStepResult struct {
  	ID         string
  	RunID      string
  	StepName   string
  	Status     string // pending, running, success, failed, skipped
  	ExitCode   int
  	Stdout     string
  	Stderr     string
  	StartedAt  time.Time
  	FinishedAt time.Time
  	DurationMs int64
  	Error      string
  }


id: DagStepResult_go_infra
name: DagStepResult
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["dag","execution","step","result"]
uses_types: ["DagRun_go_infra"]
file_path: functions/infra/dag_run.go
created_at: 2026-04-12T11:03:41Z
updated_at: 2026-04-12T11:03:41Z
notes: Status puede ser: pending, running, success, failed, skipped.
content_hash: 533c9a29876b9c0cafa64272d42f2aa3696c53cf37f42690627ad60dd4c953cd
definition:
  type DagStepResult struct {
      ID         string
      RunID      string
      StepName   string
      Status     string
      ExitCode   int
      Stdout     string
      Stderr     string
      StartedAt  time.Time
      FinishedAt time.Time
      DurationMs int64
      Error      string
  }

description:
  Resultado de la ejecucion de un step individual dentro de un DagRun. Captura exit code, stdout, stderr y duracion.

code:
  package infra

  import "time"

  // DagRun represents one execution of a DAG workflow.
  type DagRun struct {
  	ID         string
  	DagName    string
  	DagPath    string
  	Status     string // pending, running, success, failed, cancelled
  	StartedAt  time.Time
  	FinishedAt time.Time
  	Trigger    string // manual, cron, api
  	Error      string
  }

  // DagStepResult represents the outcome of one step within a DagRun.
  type DagStepResult struct {
  	ID         string
  	RunID      string
  	StepName   string
  	Status     string // pending, running, success, failed, skipped
  	ExitCode   int
  	Stdout     string
  	Stderr     string
  	StartedAt  time.Time
  	FinishedAt time.Time
  	DurationMs int64
  	Error      string
  }


id: db_config_go_infra
name: db_config
lang: go
domain: infra
version: 1.0.0
algebraic: product
description: Parametros de conexion para cualquier base de datos soportada. Agnóstico al driver.
tags: ["database","config","connection","sqlite","duckdb","postgres","clickhouse"]
uses_types: []
file_path: functions/infra/db_config.go
created_at: 2026-04-01T18:31:19Z
updated_at: 2026-04-01T18:31:19Z
content_hash: 87dbf1bea34df9821f2c850af62b50d23e833eb6365aa71e930a9b01e095ec44
definition:
  type DBConfig struct {
      Driver string            // "sqlite", "duckdb", "postgres", "clickhouse"
      DSN    string            // Data source name / connection string
      Opts   map[string]string // Driver-specific options (optional)
  }

notes:
  Tipo producto — todos los campos siempre presentes. Driver toma uno de los valores: "sqlite", "duckdb", "postgres", "clickhouse". DSN es el connection string nativo del driver. Opts permite pasar opciones adicionales especificas del driver sin necesidad de un tipo por driver.

code:
  package infra

  // DBConfig holds connection parameters for any supported database.
  type DBConfig struct {
  	Driver string            // "sqlite", "duckdb", "postgres", "clickhouse"
  	DSN    string            // Data source name / connection string
  	Opts   map[string]string // Driver-specific options (optional)
  }


id: DeployConfig_go_infra
name: DeployConfig
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["deploy","config","vps","remote","infra"]
uses_types: []
file_path: functions/infra/deploy_config.go
created_at: 2026-04-12T12:48:21Z
updated_at: 2026-04-12T12:48:21Z
content_hash: 23863a73d908876ce0ed34e5950b46339bd895cb71c07806fad189481ac2dd31
definition:
  type DeployConfig struct { AppName, LocalDir, RemoteDir, BinaryName, BuildCmd, ServiceUser string; Port int; HealthPath string; Env map[string]string }

description:
  Parametriza un deploy de app a un VPS remoto. Agrupa nombre, rutas, build, servicio, puerto, health check y env vars.

examples:
  ```go
  cfg := DeployConfig{
      AppName:     "dag_engine",
      LocalDir:    "apps/dag_engine",
      RemoteDir:   "/opt/apps/dag_engine",
      BinaryName:  "dag_engine",
      BuildCmd:    "CGO_ENABLED=0 GOOS=linux go build -o dag_engine .",
      ServiceUser: "deploy",
      Port:        8080,
      HealthPath:  "/api/health",
      Env:         map[string]string{"DB_PATH": "/opt/apps/dag_engine/data/ops.db"},
  }
  ```

notes:
  Usado por los pipelines `setup_vps_app` y `deploy_app_remote`. El campo `BuildCmd` se ejecuta localmente con `bash -c` en el directorio `LocalDir`. Si `HealthPath` está vacío o `Port` es 0, se omite el health check.

code:
  package infra

  // DeployConfig parametriza un deploy de app a un VPS remoto.
  type DeployConfig struct {
  	AppName     string            // nombre de la app (usado para systemd unit y logging)
  	LocalDir    string            // directorio local de la app (ej: apps/dag_engine)
  	RemoteDir   string            // directorio destino en el VPS (ej: /opt/apps/dag_engine)
  	BinaryName  string            // nombre del binario compilado (ej: dag_engine)
  	BuildCmd    string            // comando de build (ej: CGO_ENABLED=0 GOOS=linux go build -o dag_engine .)
  	ServiceUser string            // usuario del sistema para el servicio (vacío = sin crear)
  	Port        int               // puerto del servicio (0 si no expone HTTP)
  	HealthPath  string            // path del health check (ej: /api/health, vacío = sin check)
  	Env         map[string]string // variables de entorno para el servicio
  }


id: EmailAttachment_go_infra
name: EmailAttachment
lang: go
domain: infra
version: 1.0.0
algebraic: product
description: Archivo adjunto a un email. Contiene nombre, MIME type y datos binarios.
tags: ["email","smtp","attachment","mime"]
uses_types: []
file_path: functions/infra/email_types.go
created_at: 2026-04-13T00:03:20Z
updated_at: 2026-04-13T00:03:20Z
content_hash: 031684c4781cb0da517df7e9ccd2caa36610f0b89818794ac8a4d216b78161f0
definition:
  type EmailAttachment struct {
      Filename    string
      ContentType string
      Data        []byte
  }

examples:
  ```go
  att := EmailAttachment{
      Filename:    "report.pdf",
      ContentType: "application/pdf",
      Data:        pdfBytes,
  }
  ```

notes:
  Tipo producto — todos los campos obligatorios. `Data` es el contenido binario crudo; se codifica en base64 al serializar en MIME. Usado por `EmailMessage.Attachments`.

code:
  package infra

  // EmailAttachment represents a file attached to an email.
  type EmailAttachment struct {
  	Filename    string // nombre del archivo (ej: "report.pdf")
  	ContentType string // MIME type (ej: "application/pdf", "image/png")
  	Data        []byte // contenido binario del adjunto
  }

  // EmailMessage is an immutable email ready to send.
  // Build with EmailBuildHTML, EmailBuildText, or EmailWithAttachment.
  type EmailMessage struct {
  	From        string            // direccion del remitente (ej: "Alice <alice@example.com>")
  	To          []string          // destinatarios principales
  	CC          []string          // copia (puede ser nil)
  	BCC         []string          // copia oculta (puede ser nil)
  	Subject     string            // asunto del mensaje
  	BodyHTML    string            // cuerpo HTML (puede ser vacio si BodyText esta presente)
  	BodyText    string            // cuerpo texto plano (puede ser vacio si BodyHTML esta presente)
  	Attachments []EmailAttachment // adjuntos (puede ser nil)
  	Headers     map[string]string // headers MIME adicionales (ej: "X-Mailer": "fn_registry")
  }

  // SMTPConfig holds connection parameters for an SMTP server.
  // TLSMode controls the encryption strategy:
  //
  //	"tls"      — TLS directo (port 465)
  //	"starttls" — STARTTLS upgrade (port 587)
  //	""         — sin cifrado (port 25)
  type SMTPConfig struct {
  	Host     string // hostname del servidor SMTP (ej: "smtp.gmail.com")
  	Port     int    // puerto (465, 587, 25)
  	Username string // usuario/email de autenticacion
  	Password string // contrasena o app password
  	TLSMode  string // "tls", "starttls" o "" (sin cifrado)
  }


id: EmailMessage_go_infra
name: EmailMessage
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["email","smtp","message","mime"]
uses_types: ["EmailAttachment_go_infra"]
file_path: functions/infra/email_types.go
created_at: 2026-04-13T00:03:20Z
updated_at: 2026-04-13T00:03:20Z
content_hash: 506c27a2bf8c38ad9704a90493ddeba62394c455fc34f8a15daf10f176f38332
definition:
  type EmailMessage struct {
      From        string
      To          []string
      CC          []string
      BCC         []string
      Subject     string
      BodyHTML    string
      BodyText    string
      Attachments []EmailAttachment
      Headers     map[string]string
  }

description:
  Mensaje de email listo para enviar. Inmutable — construir con EmailBuildHTML, EmailBuildText o EmailWithAttachment.

examples:
  ```go
  msg := EmailBuildHTML("alice@example.com", []string{"bob@example.com"}, "Hola", "<b>Hola Bob</b>")
  ```

notes:
  Tipo producto. `BodyHTML` y `BodyText` son opcionales pero al menos uno debe estar presente para que el mensaje sea valido. `Attachments` puede ser nil. `Headers` permite agregar cabeceras MIME custom como `X-Mailer`.

code:
  package infra

  // EmailAttachment represents a file attached to an email.
  type EmailAttachment struct {
  	Filename    string // nombre del archivo (ej: "report.pdf")
  	ContentType string // MIME type (ej: "application/pdf", "image/png")
  	Data        []byte // contenido binario del adjunto
  }

  // EmailMessage is an immutable email ready to send.
  // Build with EmailBuildHTML, EmailBuildText, or EmailWithAttachment.
  type EmailMessage struct {
  	From        string            // direccion del remitente (ej: "Alice <alice@example.com>")
  	To          []string          // destinatarios principales
  	CC          []string          // copia (puede ser nil)
  	BCC         []string          // copia oculta (puede ser nil)
  	Subject     string            // asunto del mensaje
  	BodyHTML    string            // cuerpo HTML (puede ser vacio si BodyText esta presente)
  	BodyText    string            // cuerpo texto plano (puede ser vacio si BodyHTML esta presente)
  	Attachments []EmailAttachment // adjuntos (puede ser nil)
  	Headers     map[string]string // headers MIME adicionales (ej: "X-Mailer": "fn_registry")
  }

  // SMTPConfig holds connection parameters for an SMTP server.
  // TLSMode controls the encryption strategy:
  //
  //	"tls"      — TLS directo (port 465)
  //	"starttls" — STARTTLS upgrade (port 587)
  //	""         — sin cifrado (port 25)
  type SMTPConfig struct {
  	Host     string // hostname del servidor SMTP (ej: "smtp.gmail.com")
  	Port     int    // puerto (465, 587, 25)
  	Username string // usuario/email de autenticacion
  	Password string // contrasena o app password
  	TLSMode  string // "tls", "starttls" o "" (sin cifrado)
  }


id: HTTPError_go_infra
name: HTTPError
lang: go
domain: infra
version: 1.0.0
algebraic: product
definition:
  type HTTPError struct {
  Status int
  Code string
  Message string
  }

tags: ["http","error","server","json","infra"]
uses_types: []
file_path: functions/infra/http_error.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: 6effae339f71fcbee6b0eafe11ac33ae75cb066a16e4190d0a4e323f9ad8ebbe
description:
  Error HTTP estructurado para respuestas JSON. Combina status code HTTP, codigo de error legible por maquina y mensaje para el usuario.

examples:
  ```go
  err := HTTPError{
      Status:  400,
      Code:    "invalid_input",
      Message: "el campo email es requerido",
  }
  HTTPErrorResponse(w, err)
  ```

notes:
  Tipo producto — los tres campos son complementarios. Status es el codigo HTTP numerico que se usa en la respuesta. Code es un identificador maquina (snake_case) para el cliente. Message es texto libre legible por humanos. Se serializa a JSON con HTTPErrorResponse.

code:
  package infra

  // HTTPError representa un error HTTP estructurado para respuestas JSON.
  type HTTPError struct {
  	Status  int    // codigo HTTP (ej: 400, 404, 500)
  	Code    string // codigo de error legible por maquina (ej: "invalid_input")
  	Message string // mensaje de error legible por humano
  }


id: Middleware_go_infra
name: Middleware
lang: go
domain: infra
version: 1.0.0
algebraic: product
definition: type Middleware func(http.Handler) http.Handler
description: Funcion que envuelve un http.Handler para añadir comportamiento (logging, auth, CORS, etc.).
tags: ["http","middleware","server","handler","infra"]
uses_types: []
file_path: functions/infra/http_middleware.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: 7e037021df9fd4c2d42f2b92fd55f905de99275e8ceafee3982f878c878133f6
examples:
  ```go
  // Middleware que añade un header a cada respuesta
  func myMiddleware(next http.Handler) http.Handler {
      return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
          w.Header().Set("X-Custom", "value")
          next.ServeHTTP(w, r)
      })
  }
  ```

notes:
  Tipo funcion — aplica el patron decorator sobre http.Handler. Se compone con HTTPMiddlewareChain para apilar comportamiento en orden. Cada Middleware recibe el siguiente handler y retorna un nuevo handler que puede modificar request y response.

code:
  package infra

  import "net/http"

  // Middleware es una funcion que envuelve un http.Handler para añadir comportamiento.
  type Middleware func(http.Handler) http.Handler


id: Route_go_infra
name: Route
lang: go
domain: infra
version: 1.0.0
algebraic: product
definition:
  type Route struct {
  Method string
  Path string
  Handler http.Handler
  }

tags: ["http","route","server","router","infra"]
uses_types: []
file_path: functions/infra/http_route.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: 619156d6679d718fcf9b3b41c14e67ecc98934deac61991980d99712c5ff1c14
description:
  Define una ruta HTTP con metodo, path y handler. Se usa con HTTPRouter para registrar rutas en un ServeMux.

examples:
  ```go
  routes := []Route{
      {Method: "GET", Path: "/api/health", Handler: http.HandlerFunc(healthHandler)},
      {Method: "POST", Path: "/api/users", Handler: http.HandlerFunc(createUserHandler)},
  }
  mux := HTTPRouter(routes)
  ```

notes:
  Tipo producto — los tres campos son obligatorios. Method debe ser un verbo HTTP valido en mayusculas. Path sigue la sintaxis del ServeMux de Go 1.22+ (soporta parametros como {id}). Handler puede ser cualquier http.Handler o http.HandlerFunc.

code:
  package infra

  import "net/http"

  // Route define una ruta HTTP con metodo, path y handler.
  type Route struct {
  	Method  string       // metodo HTTP (GET, POST, PUT, DELETE, PATCH, etc.)
  	Path    string       // path de la ruta (ej: /api/users/{id})
  	Handler http.Handler // handler que procesa la peticion
  }


id: image_info_go_infra
name: image_info
lang: go
domain: infra
version: 1.0.0
algebraic: product
description: Información básica de una imagen Docker local: ID, repositorio, tag, tamaño, fecha.
tags: ["docker","image","infra"]
uses_types: []
file_path: functions/infra/image_info.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
notes: Tipo product que modela la salida de `docker images`. Los campos mapean al formato JSON de Docker.
content_hash: f58304810989e8d4277cc3af16db494f73d5b0d6ffa620d21d31b249825e6245
definition:
  type ImageInfo struct {
    ID         string
    Repository string
    Tag        string
    Size       string
    Created    string
  }

examples:
  ```go
  img := ImageInfo{
      ID:         "sha256:abc123",
      Repository: "nginx",
      Tag:        "latest",
      Size:       "142MB",
      Created:    "2 weeks ago",
  }
  ```

code:
  package infra

  // ImageInfo representa la información básica de una imagen Docker local.
  type ImageInfo struct {
  	ID         string // ID corto de la imagen
  	Repository string // Nombre del repositorio
  	Tag        string // Tag de la imagen
  	Size       string // Tamaño legible (ej: "142MB")
  	Created    string // Fecha de creación
  }


id: job_go_infra
name: job
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["job","queue","async","background","sqlite","infra"]
uses_types: ["job_status_go_infra"]
file_path: functions/infra/job_queue_types.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: 1b665274dfb29db225c221d8a8bc483b02258509e429c1085364b86be4d92610
definition:
  type Job struct {
      ID          string
      Type        string
      Payload     string
      Status      JobStatus
      Priority    int
      Attempts    int
      MaxAttempts int
      ScheduledAt time.Time
      StartedAt   *time.Time
      CompletedAt *time.Time
      Result      *string
      Error       *string
      CreatedAt   time.Time
  }

description:
  Unidad de trabajo asincrono almacenada en SQLite. Payload es un JSON string. Status evoluciona de pending a running y luego a completed, failed o dead.

notes:
  Tipo producto. Los campos `StartedAt`, `CompletedAt`, `Result` y `Error` son punteros — son nil hasta que el job alcanza el estado correspondiente. `Payload` es siempre un JSON string (minimo "{}").

code:
  package infra

  import (
  	"database/sql"
  	"time"
  )

  // JobStatus represents the lifecycle state of a job.
  type JobStatus string

  const (
  	JobStatusPending   JobStatus = "pending"
  	JobStatusRunning   JobStatus = "running"
  	JobStatusCompleted JobStatus = "completed"
  	JobStatusFailed    JobStatus = "failed"
  	JobStatusDead      JobStatus = "dead"
  )

  // Job represents a unit of asynchronous work stored in SQLite.
  type Job struct {
  	ID          string
  	Type        string
  	Payload     string    // JSON string
  	Status      JobStatus
  	Priority    int
  	Attempts    int
  	MaxAttempts int
  	ScheduledAt time.Time
  	StartedAt   *time.Time
  	CompletedAt *time.Time
  	Result      *string
  	Error       *string
  	CreatedAt   time.Time
  }

  // JobQueue wraps a *sql.DB and the jobs table name.
  type JobQueue struct {
  	DB        *sql.DB
  	TableName string
  }

  // JobHandler is the function signature for processing a job.
  // Return a non-nil error to trigger retry / fail logic.
  type JobHandler func(job Job) error

  // EnqueueOption configures optional behaviour of JobEnqueue.
  type EnqueueOption func(*enqueueConfig)

  type enqueueConfig struct {
  	priority    int
  	scheduledAt time.Time
  	maxAttempts int
  }

  // WithPriority sets the job priority (higher = processed first).
  func WithPriority(p int) EnqueueOption {
  	return func(c *enqueueConfig) { c.priority = p }
  }

  // WithScheduledAt delays execution until the given time.
  func WithScheduledAt(t time.Time) EnqueueOption {
  	return func(c *enqueueConfig) { c.scheduledAt = t }
  }

  // WithMaxAttempts sets how many times the job may be attempted before dying.
  func WithMaxAttempts(n int) EnqueueOption {
  	return func(c *enqueueConfig) { c.maxAttempts = n }
  }

  // WorkerOption configures optional behaviour of JobWorker / JobWorkerPool.
  type WorkerOption func(*workerConfig)

  type workerConfig struct {
  	pollInterval time.Duration
  	jobTypes     []string
  }

  // WithPollInterval sets how long the worker sleeps between dequeue attempts.
  func WithPollInterval(d time.Duration) WorkerOption {
  	return func(c *workerConfig) { c.pollInterval = d }
  }

  // WithJobTypes restricts the worker to the given job types.
  // An empty list means all types.
  func WithJobTypes(types ...string) WorkerOption {
  	return func(c *workerConfig) { c.jobTypes = types }
  }


id: job_queue_go_infra
name: job_queue
lang: go
domain: infra
version: 1.0.0
algebraic: product
definition:
  type JobQueue struct {
  DB *sql.DB
  TableName string
  }

tags: ["job","queue","sqlite","infra","async"]
uses_types: []
file_path: functions/infra/job_queue_types.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: b9b38f5445d3fe7286564dc06117976264b0c2f11e7653908590d0a9db461bf5
description:
  Handle para una cola de jobs SQLite. Wrappea un *sql.DB y el nombre de la tabla de jobs. Se crea con JobQueueCreate.

notes:
  Tipo producto minimo — todas las funciones del subsistema de jobs lo reciben como primer argumento. TableName permite tener multiples colas en la misma DB. Se crea con `JobQueueCreate`.

code:
  package infra

  import (
  	"database/sql"
  	"time"
  )

  // JobStatus represents the lifecycle state of a job.
  type JobStatus string

  const (
  	JobStatusPending   JobStatus = "pending"
  	JobStatusRunning   JobStatus = "running"
  	JobStatusCompleted JobStatus = "completed"
  	JobStatusFailed    JobStatus = "failed"
  	JobStatusDead      JobStatus = "dead"
  )

  // Job represents a unit of asynchronous work stored in SQLite.
  type Job struct {
  	ID          string
  	Type        string
  	Payload     string    // JSON string
  	Status      JobStatus
  	Priority    int
  	Attempts    int
  	MaxAttempts int
  	ScheduledAt time.Time
  	StartedAt   *time.Time
  	CompletedAt *time.Time
  	Result      *string
  	Error       *string
  	CreatedAt   time.Time
  }

  // JobQueue wraps a *sql.DB and the jobs table name.
  type JobQueue struct {
  	DB        *sql.DB
  	TableName string
  }

  // JobHandler is the function signature for processing a job.
  // Return a non-nil error to trigger retry / fail logic.
  type JobHandler func(job Job) error

  // EnqueueOption configures optional behaviour of JobEnqueue.
  type EnqueueOption func(*enqueueConfig)

  type enqueueConfig struct {
  	priority    int
  	scheduledAt time.Time
  	maxAttempts int
  }

  // WithPriority sets the job priority (higher = processed first).
  func WithPriority(p int) EnqueueOption {
  	return func(c *enqueueConfig) { c.priority = p }
  }

  // WithScheduledAt delays execution until the given time.
  func WithScheduledAt(t time.Time) EnqueueOption {
  	return func(c *enqueueConfig) { c.scheduledAt = t }
  }

  // WithMaxAttempts sets how many times the job may be attempted before dying.
  func WithMaxAttempts(n int) EnqueueOption {
  	return func(c *enqueueConfig) { c.maxAttempts = n }
  }

  // WorkerOption configures optional behaviour of JobWorker / JobWorkerPool.
  type WorkerOption func(*workerConfig)

  type workerConfig struct {
  	pollInterval time.Duration
  	jobTypes     []string
  }

  // WithPollInterval sets how long the worker sleeps between dequeue attempts.
  func WithPollInterval(d time.Duration) WorkerOption {
  	return func(c *workerConfig) { c.pollInterval = d }
  }

  // WithJobTypes restricts the worker to the given job types.
  // An empty list means all types.
  func WithJobTypes(types ...string) WorkerOption {
  	return func(c *workerConfig) { c.jobTypes = types }
  }


id: job_status_go_infra
name: job_status
lang: go
domain: infra
version: 1.0.0
algebraic: sum
tags: ["job","status","queue","async","infra"]
uses_types: []
file_path: functions/infra/job_queue_types.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: aad4ec79811ae4dd2606009d8871aef7c57cb655c2da98a6d3e9922e866d7434
definition:
  type JobStatus string

  const (
      JobStatusPending   JobStatus = "pending"
      JobStatusRunning   JobStatus = "running"
      JobStatusCompleted JobStatus = "completed"
      JobStatusFailed    JobStatus = "failed"
      JobStatusDead      JobStatus = "dead"
  )

description:
  Estado del ciclo de vida de un job. Tipo suma con cinco variantes: pending, running, completed, failed, dead.

notes:
  Tipo suma. Ciclo de vida normal: pending → running → completed. En error: pending → running → failed (reintentos posibles) → dead (sin mas intentos). Un job con `attempts >= max_attempts` pasa directamente a dead.

code:
  package infra

  import (
  	"database/sql"
  	"time"
  )

  // JobStatus represents the lifecycle state of a job.
  type JobStatus string

  const (
  	JobStatusPending   JobStatus = "pending"
  	JobStatusRunning   JobStatus = "running"
  	JobStatusCompleted JobStatus = "completed"
  	JobStatusFailed    JobStatus = "failed"
  	JobStatusDead      JobStatus = "dead"
  )

  // Job represents a unit of asynchronous work stored in SQLite.
  type Job struct {
  	ID          string
  	Type        string
  	Payload     string    // JSON string
  	Status      JobStatus
  	Priority    int
  	Attempts    int
  	MaxAttempts int
  	ScheduledAt time.Time
  	StartedAt   *time.Time
  	CompletedAt *time.Time
  	Result      *string
  	Error       *string
  	CreatedAt   time.Time
  }

  // JobQueue wraps a *sql.DB and the jobs table name.
  type JobQueue struct {
  	DB        *sql.DB
  	TableName string
  }

  // JobHandler is the function signature for processing a job.
  // Return a non-nil error to trigger retry / fail logic.
  type JobHandler func(job Job) error

  // EnqueueOption configures optional behaviour of JobEnqueue.
  type EnqueueOption func(*enqueueConfig)

  type enqueueConfig struct {
  	priority    int
  	scheduledAt time.Time
  	maxAttempts int
  }

  // WithPriority sets the job priority (higher = processed first).
  func WithPriority(p int) EnqueueOption {
  	return func(c *enqueueConfig) { c.priority = p }
  }

  // WithScheduledAt delays execution until the given time.
  func WithScheduledAt(t time.Time) EnqueueOption {
  	return func(c *enqueueConfig) { c.scheduledAt = t }
  }

  // WithMaxAttempts sets how many times the job may be attempted before dying.
  func WithMaxAttempts(n int) EnqueueOption {
  	return func(c *enqueueConfig) { c.maxAttempts = n }
  }

  // WorkerOption configures optional behaviour of JobWorker / JobWorkerPool.
  type WorkerOption func(*workerConfig)

  type workerConfig struct {
  	pollInterval time.Duration
  	jobTypes     []string
  }

  // WithPollInterval sets how long the worker sleeps between dequeue attempts.
  func WithPollInterval(d time.Duration) WorkerOption {
  	return func(c *workerConfig) { c.pollInterval = d }
  }

  // WithJobTypes restricts the worker to the given job types.
  // An empty list means all types.
  func WithJobTypes(types ...string) WorkerOption {
  	return func(c *workerConfig) { c.jobTypes = types }
  }


id: JWTClaims_go_infra
name: JWTClaims
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["jwt","auth","token","claims","infra"]
uses_types: []
file_path: functions/infra/jwt_claims.go
created_at: 2026-04-18T15:44:12Z
updated_at: 2026-04-18T15:44:12Z
content_hash: bae22b5395043716951f799e180c492af60215f133251e3abb20059f6ba52e95
definition:
  type JWTClaims struct {
      Subject   string         `json:"sub"`
      Issuer    string         `json:"iss,omitempty"`
      Audience  string         `json:"aud,omitempty"`
      ExpiresAt int64          `json:"exp"`
      IssuedAt  int64          `json:"iat"`
      Custom    map[string]any `json:"custom,omitempty"`
  }

description:
  Claims de un JSON Web Token. Incluye los campos registrados (sub, iss, aud, exp, iat) y un mapa Custom libre para claims de aplicacion como role o email.

examples:
  ```go
  claims := JWTClaims{
      Subject:   "user-123",
      Issuer:    "my-api",
      ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
      Custom: map[string]any{
          "role":  "admin",
          "email": "alice@example.com",
      },
  }
  token, _ := JWTGenerate(claims, secret)
  ```

notes:
  Tipo producto — los campos estandar cubren RFC 7519. ExpiresAt y IssuedAt son Unix epoch seconds. JWTGenerate setea IssuedAt automaticamente si viene en cero. Custom se serializa bajo la clave "custom" en el payload JSON para evitar colisiones con claims registrados. Para leer valores custom de forma segura tras JWTValidate: `v, ok := claims.Custom["role"].(string)`.

code:
  package infra

  // JWTClaims contiene claims estandar y custom para un JWT.
  // Incluye los campos registrados mas comunes (sub, iss, aud, exp, iat)
  // y un mapa libre `Custom` para claims de aplicacion (ej: role, email).
  type JWTClaims struct {
  	Subject   string         `json:"sub"`
  	Issuer    string         `json:"iss,omitempty"`
  	Audience  string         `json:"aud,omitempty"`
  	ExpiresAt int64          `json:"exp"`
  	IssuedAt  int64          `json:"iat"`
  	Custom    map[string]any `json:"custom,omitempty"`
  }


id: LogEntry_go_infra
name: LogEntry
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["logging","log","entry","json","infra"]
uses_types: []
file_path: functions/infra/log_entry.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
content_hash: 19da1b727d26123b8a90970509803bb55307570af01f7e135328bea2d58882c6
definition:
  type LogEntry struct {
      Timestamp time.Time      `json:"timestamp"`
      Level     string         `json:"level"`
      Message   string         `json:"message"`
      Fields    map[string]any `json:"fields,omitempty"`
  }

description:
  Entrada de log estructurada serializable a JSON. Modelo canonico para tests y pipelines de procesamiento de logs.

examples:
  ```go
  entry := LogEntry{
      Timestamp: time.Now(),
      Level:     "INFO",
      Message:   "server starting",
      Fields:    map[string]any{"port": 8484, "app": "api"},
  }
  data, _ := json.Marshal(entry)
  // {"timestamp":"2026-04-18T10:00:00Z","level":"INFO","message":"server starting","fields":{"app":"api","port":8484}}
  ```

notes:
  Tipo producto — cuatro campos, todos exportados. El formato JSON de slog usa claves diferentes (`time`, `level`, `msg`) pero este tipo sirve como adaptador cuando se deserializan logs para tests o para pipelines downstream. `Fields` es opcional (omitempty) y permite adjuntar cualquier contexto key-value.

code:
  package infra

  import "time"

  // LogEntry representa una entrada de log estructurada serializable a JSON.
  // Se usa como modelo canonico para tests y para pipelines que procesan logs.
  type LogEntry struct {
  	Timestamp time.Time      `json:"timestamp"`
  	Level     string         `json:"level"`
  	Message   string         `json:"message"`
  	Fields    map[string]any `json:"fields,omitempty"`
  }


id: LogLevel_go_infra
name: LogLevel
lang: go
domain: infra
version: 1.0.0
algebraic: sum
tags: ["logging","log","level","slog","infra"]
uses_types: []
file_path: functions/infra/log_level.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
content_hash: 098a88db5fc9c9fbbb8cb2f5a5c69984d7583839dec19da95bb9a2960a0dd16f
definition:
  type LogLevel int

  const (
      LogLevelDebug LogLevel = iota
      LogLevelInfo
      LogLevelWarn
      LogLevelError
  )

description:
  Nivel de log soportado por el Logger. Los valores ordenados de menor a mayor severidad son Debug, Info, Warn y Error.

examples:
  ```go
  logger, _ := LoggerNew(LogLevelInfo, os.Stdout, "json")
  LogDebug(logger, "no se imprime") // filtrado porque Debug < Info
  LogInfo(logger, "server up")      // se imprime
  ```

notes:
  Tipo suma — los cuatro valores son exhaustivos y se corresponden uno a uno con los niveles de `log/slog` (slog.LevelDebug, slog.LevelInfo, slog.LevelWarn, slog.LevelError). El orden de comparacion sigue la convencion clasica: cuanto mas alto, mas severo. Util para configurar filtrado en LoggerNew.

code:
  package infra

  // LogLevel representa los niveles de log soportados por el Logger.
  // El orden implicito es Debug < Info < Warn < Error.
  type LogLevel int

  const (
  	// LogLevelDebug es el nivel mas verbose, util para trazas de desarrollo.
  	LogLevelDebug LogLevel = iota
  	// LogLevelInfo es el nivel por defecto para eventos normales del sistema.
  	LogLevelInfo
  	// LogLevelWarn indica situaciones anomalas que no impiden el funcionamiento.
  	LogLevelWarn
  	// LogLevelError indica fallos que requieren atencion.
  	LogLevelError
  )


id: Logger_go_infra
name: Logger
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["logging","log","slog","logger","infra"]
uses_types: ["LogLevel_go_infra"]
file_path: functions/infra/logger.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
content_hash: ff20ac4dadf232fac76535e9a893bc8228488228335e8a3889b2c3fb0ee05e40
definition:
  type Logger struct {
      Level  LogLevel
      Output io.Writer
      Format string
      Fields map[string]any
      inner  *slog.Logger
  }

description:
  Wrapper sobre slog.Logger con config del registry: nivel, destino (io.Writer), formato (json/text) y campos contextuales inmutables.

examples:
  ```go
  logger, err := LoggerNew(LogLevelInfo, os.Stdout, "json")
  if err != nil {
      log.Fatal(err)
  }
  appLog := LoggerWith(logger, map[string]any{"app": "sqlite_api"})
  LogInfo(appLog, "server starting", "port", 8484)
  ```

notes:
  Tipo producto — los campos publicos describen la config y los fields contextuales. El campo privado `inner` es la instancia real de `slog.Logger` que escribe. Se construye con `LoggerNew` (impuro) y se clona con `LoggerWith` (puro). Nunca se muta despues de creado.

code:
  package infra

  import (
  	"io"
  	"log/slog"
  )

  // Logger wrappea slog.Logger con config del registry (nivel, output, formato, campos contextuales).
  // Se crea con LoggerNew y se clona inmutablemente con LoggerWith anadiendo campos.
  type Logger struct {
  	Level  LogLevel       // nivel minimo filtrado
  	Output io.Writer      // destino de los logs (stdout, stderr, file, buffer)
  	Format string         // "json" | "text"
  	Fields map[string]any // campos contextuales adjuntos al logger
  	inner  *slog.Logger   // handler real de slog
  }


id: MetabaseClient_go_infra
name: MetabaseClient
lang: go
domain: infra
version: 1.0.0
algebraic: product
definition:
  type MetabaseClient struct {
  BaseURL string
  Token string
  }

tags: ["metabase","api","client","infra"]
uses_types: []
file_path: functions/infra/metabase_client.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: e95a90c25f7e3a2522415d052fc07c3b9e280fde42d80064768f3014450908f5
description:
  Cliente para la API REST de Metabase. Contiene la URL base de la instancia y el token de autenticacion (session token o API key).

notes:
  Tipo producto con dos campos obligatorios:
  - `BaseURL`: URL base de la instancia Metabase sin trailing slash (ej: `http://localhost:3000`)
  - `Token`: token de sesion obtenido con `MetabaseAuth()` o una API key creada en el admin UI de Metabase

  El token se envia como header `X-Metabase-Session` en session tokens o `x-api-key` en API keys. Las funciones del registry usan `X-Metabase-Session` por defecto.

code:
  package infra

  // MetabaseClient holds the connection details for a Metabase instance API.
  type MetabaseClient struct {
  	BaseURL string // e.g. "http://localhost:3000"
  	Token   string // session token or API key
  }


id: Migration_go_infra
name: Migration
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["migration","database","sql","schema","sqlite"]
uses_types: []
file_path: functions/infra/migration.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: f5ec74ec4698b63bd59c805c65a8e2b25c799c603faae747cc98b4f8b8699cb9
definition:
  type Migration struct {
      Version   int
      Name      string
      UpSQL     string
      DownSQL   string
      AppliedAt time.Time
  }

description:
  Migracion SQL parseada desde un archivo .sql con marcadores -- +up / -- +down. Version es el numero secuencial, AppliedAt es zero value si pendiente.

notes:
  Tipo producto — todos los campos siempre presentes. `AppliedAt` es `time.Time{}` (zero value) si la migracion aun no fue aplicada. `UpSQL` y `DownSQL` son el contenido de los bloques delimitados por `-- +up` y `-- +down` en el archivo .sql.

code:
  package infra

  import "time"

  // Migration represents a migration parsed from a .sql file.
  type Migration struct {
  	Version   int       // sequential number (1, 2, 3...)
  	Name      string    // descriptive name (create_entities, add_status_column)
  	UpSQL     string    // SQL block to apply the migration
  	DownSQL   string    // SQL block to revert the migration
  	AppliedAt time.Time // zero value if not yet applied
  }

  // MigrationStatus represents the state of a migration relative to a database.
  type MigrationStatus struct {
  	Version   int       // sequential number
  	Name      string    // descriptive name
  	Applied   bool      // true if already applied in the database
  	AppliedAt time.Time // when it was applied (zero value if pending)
  }


id: MigrationStatus_go_infra
name: MigrationStatus
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["migration","database","sql","schema","sqlite","status"]
uses_types: []
file_path: functions/infra/migration.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: 51933c3681cc6fae10d99af43b30b08409320a1bde0fe79a563aa1d8ea90b6ad
definition:
  type MigrationStatus struct {
      Version   int
      Name      string
      Applied   bool
      AppliedAt time.Time
  }

description:
  Estado de una migracion respecto a una base de datos concreta. Applied=true si ya fue ejecutada, AppliedAt indica cuando.

notes:
  Tipo producto. `Applied` es `false` y `AppliedAt` es zero value para migraciones pendientes. Se usa como resultado de `migration_status` para cruzar archivos en disco con registros en `_migrations`.

code:
  package infra

  import "time"

  // Migration represents a migration parsed from a .sql file.
  type Migration struct {
  	Version   int       // sequential number (1, 2, 3...)
  	Name      string    // descriptive name (create_entities, add_status_column)
  	UpSQL     string    // SQL block to apply the migration
  	DownSQL   string    // SQL block to revert the migration
  	AppliedAt time.Time // zero value if not yet applied
  }

  // MigrationStatus represents the state of a migration relative to a database.
  type MigrationStatus struct {
  	Version   int       // sequential number
  	Name      string    // descriptive name
  	Applied   bool      // true if already applied in the database
  	AppliedAt time.Time // when it was applied (zero value if pending)
  }


id: NordVPNStatus_go_infra
name: NordVPNStatus
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["vpn","nordvpn","status","infra"]
uses_types: []
file_path: functions/infra/parse_nordvpn_status.go
created_at: 2026-04-01T14:14:49Z
updated_at: 2026-04-01T14:14:49Z
content_hash: 5f8a8c6a2e17490901f478cd8adb7761e688e9d0efea96a4e9ff1e63a3a1cb71
definition:
  type NordVPNStatus struct { Connected bool; Status string; Hostname string; IP string; Country string; City string; Technology string; Protocol string; Transfer string; Uptime string }

description:
  Estado parseado de nordvpn status. Contiene informacion de conexion, servidor, ubicacion y protocolo.

examples:
  ```go
  s := NordVPNStatus{
      Connected:  true,
      Status:     "Connected",
      Hostname:   "es42.nordvpn.com",
      IP:         "185.230.124.42",
      Country:    "Spain",
      City:       "Madrid",
      Technology: "NordLynx",
      Protocol:   "nordlynx",
  }
  ```

notes:
  Producido por ParseNordVPNStatus. Los campos corresponden a las claves de la salida de `nordvpn status`. Transfer y Uptime son strings sin parsear — incluir parseo numerico si se necesita.

code:
  package infra

  import (
  	"regexp"
  	"strings"
  )

  // NordVPNStatus representa el estado parseado de nordvpn status.
  type NordVPNStatus struct {
  	Connected  bool   // true si hay conexion activa
  	Status     string // "Connected" o "Disconnected"
  	Hostname   string // ej: "es42.nordvpn.com"
  	IP         string // IP del servidor VPN
  	Country    string // ej: "Spain"
  	City       string // ej: "Madrid"
  	Technology string // ej: "NordLynx"
  	Protocol   string // ej: "nordlynx"
  	Transfer   string // ej: "1.2 MiB received, 500 KiB sent"
  	Uptime     string // ej: "5 minutes 32 seconds"
  }

  // ansiRegexp elimina codigos de escape ANSI.
  var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`)

  // ParseNordVPNStatus parsea la salida de texto de `nordvpn status`
  // a un struct tipado. Funcion pura — no ejecuta comandos.
  func ParseNordVPNStatus(output string) NordVPNStatus {
  	var s NordVPNStatus

  	lines := strings.Split(output, "\n")
  	for _, line := range lines {
  		line = ansiRegexp.ReplaceAllString(line, "")
  		line = strings.TrimSpace(line)
  		line = strings.TrimLeft(line, "- ")

  		idx := strings.Index(line, ":")
  		if idx < 0 {
  			continue
  		}

  		key := strings.ToLower(strings.TrimSpace(line[:idx]))
  		val := strings.TrimSpace(line[idx+1:])

  		switch key {
  		case "status":
  			s.Status = val
  			s.Connected = strings.EqualFold(val, "connected")
  		case "hostname", "server":
  			s.Hostname = val
  		case "ip":
  			s.IP = val
  		case "country":
  			s.Country = val
  		case "city":
  			s.City = val
  		case "current technology":
  			s.Technology = val
  		case "current protocol":
  			s.Protocol = val
  		case "transfer":
  			s.Transfer = val
  		case "uptime":
  			s.Uptime = val
  		}
  	}

  	return s
  }


id: OAuthConfig_go_infra
name: OAuthConfig
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["oauth","oauth2","auth","config","infra"]
uses_types: []
file_path: functions/infra/oauth_config.go
created_at: 2026-04-18T15:44:12Z
updated_at: 2026-04-18T15:44:12Z
content_hash: bc6ba61ffa64fdc518b3941a56426d0b990c75478b1016c2c447b60e71a119ba
definition:
  type OAuthConfig struct {
      ClientID     string   `json:"client_id"`
      ClientSecret string   `json:"client_secret"`
      AuthURL      string   `json:"auth_url"`
      TokenURL     string   `json:"token_url"`
      RedirectURL  string   `json:"redirect_url"`
      Scopes       []string `json:"scopes"`
  }

description:
  Configuracion de un proveedor OAuth2. Contiene credenciales del cliente, endpoints de autorizacion/token, redirect URI y scopes solicitados.

examples:
  ```go
  google := OAuthConfig{
      ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
      ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
      AuthURL:      "https://accounts.google.com/o/oauth2/v2/auth",
      TokenURL:     "https://oauth2.googleapis.com/token",
      RedirectURL:  "http://localhost:8080/callback",
      Scopes:       []string{"openid", "email", "profile"},
  }
  url := Oauth2AuthURL(google, "random-state-123")
  ```

notes:
  Tipo producto — agrupa todo lo necesario para los tres flujos OAuth2 soportados (auth URL, code exchange, refresh). ClientSecret nunca debe salir del servidor: Oauth2Exchange y Oauth2Refresh lo envian al TokenURL del proveedor, no al cliente. Scopes se concatenan con espacio al construir la URL (`openid email profile`).

code:
  package infra

  // OAuthConfig contiene la configuracion de un proveedor OAuth2.
  // Los Scopes se concatenan con espacio al construir la URL de autorizacion.
  type OAuthConfig struct {
  	ClientID     string   `json:"client_id"`
  	ClientSecret string   `json:"client_secret"`
  	AuthURL      string   `json:"auth_url"`
  	TokenURL     string   `json:"token_url"`
  	RedirectURL  string   `json:"redirect_url"`
  	Scopes       []string `json:"scopes"`
  }


id: OAuthTokens_go_infra
name: OAuthTokens
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["oauth","oauth2","token","infra"]
uses_types: []
file_path: functions/infra/oauth_tokens.go
created_at: 2026-04-18T15:44:12Z
updated_at: 2026-04-18T15:44:12Z
content_hash: 1c5c7e2b8c9c2e0f8426736bf6c7def36b166b81220768dc99b6835bb6cca601
definition:
  type OAuthTokens struct {
      AccessToken  string `json:"access_token"`
      RefreshToken string `json:"refresh_token"`
      TokenType    string `json:"token_type"`
      ExpiresAt    int64  `json:"expires_at"`
  }

description:
  Tokens OAuth2 obtenidos de un flujo de autorizacion. AccessToken es el de corta vida para llamadas a APIs. RefreshToken renueva el access. ExpiresAt es Unix epoch seconds.

examples:
  ```go
  tokens, _ := Oauth2Exchange(googleConfig, code)
  if time.Now().Unix() >= tokens.ExpiresAt {
      tokens, _ = Oauth2Refresh(googleConfig, tokens.RefreshToken)
  }
  req.Header.Set("Authorization", tokens.TokenType + " " + tokens.AccessToken)
  ```

notes:
  Tipo producto — producto del flujo OAuth2. AccessToken tipicamente expira en 1h. RefreshToken puede durar dias/meses segun el proveedor. TokenType suele ser "Bearer". ExpiresAt se calcula como `time.Now().Unix() + expires_in` al parsear la respuesta del proveedor, asi el consumidor solo compara con `time.Now().Unix()` para saber si renovar.

code:
  package infra

  // OAuthTokens contiene los tokens obtenidos de un flujo OAuth2.
  // ExpiresAt es Unix epoch seconds calculado a partir de expires_in del proveedor.
  type OAuthTokens struct {
  	AccessToken  string `json:"access_token"`
  	RefreshToken string `json:"refresh_token"`
  	TokenType    string `json:"token_type"`
  	ExpiresAt    int64  `json:"expires_at"`
  }


id: Permission_go_infra
name: Permission
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["rbac","permission","auth","infra"]
uses_types: []
file_path: functions/infra/permission.go
created_at: 2026-04-18T15:44:12Z
updated_at: 2026-04-18T15:44:12Z
content_hash: 0b1f17c36eac041114c2fdea2eb788d414aea0109942c97fa2b5771e6ee5c3f6
definition:
  type Permission struct {
      Resource string `json:"resource"`
      Action   string `json:"action"`
  }

description:
  Permiso RBAC: accion sobre un recurso. Par (resource, action) evaluado por RBACCheck contra los permisos de un rol.

examples:
  ```go
  p := Permission{Resource: "users", Action: "delete"}
  if RBACCheck(roles, "admin", p) {
      // el rol admin puede borrar usuarios
  }
  ```

notes:
  Tipo producto — Resource y Action son strings libres, decididos por la app. Convencion: Resource en plural snake_case (`users`, `articles`, `billing_invoices`), Action verbo minusculas (`read`, `write`, `delete`, `admin`). Los wildcards `*` no se interpretan — si quieres "todas las acciones" define una Permission explicita por cada una en el rol, o crea un rol superadmin fuera del sistema RBAC.

code:
  package infra

  // Permission representa una accion sobre un recurso.
  // Ejemplo: {Resource: "users", Action: "delete"}.
  type Permission struct {
  	Resource string `json:"resource"`
  	Action   string `json:"action"`
  }


id: process_handle_go_infra
name: process_handle
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["process","subprocess","handle","infra","exec"]
uses_types: []
file_path: functions/infra/process_handle.go
created_at: 2026-04-12T11:03:11Z
updated_at: 2026-04-12T11:03:11Z
content_hash: 0f8f4b74feca5ee45d41334c54991b0dc0c9ca7a79f36548de33bdfa502f3ebc
definition:
  type ProcessHandle struct {
      Cmd       *exec.Cmd
      Pid       int
      StartTime time.Time
      Dir       string
      stdout    io.ReadCloser
      stderr    io.ReadCloser
  }

description:
  Handle de un subproceso en ejecucion. Contiene el comando, PID, tiempo de inicio, directorio de trabajo y los pipes de stdout/stderr (privados, leidos internamente por ProcessWait).

notes:
  Tipo producto. Los campos stdout y stderr son privados para evitar lecturas concurrentes externas — ProcessWait los consume internamente. Cmd.SysProcAttr.Setpgid=true garantiza que ProcessKill puede matar el process group completo usando -Pid.

code:
  package infra

  import (
  	"bytes"
  	"os/exec"
  	"time"
  )

  // ProcessHandle represents a running subprocess with output buffers.
  type ProcessHandle struct {
  	Cmd       *exec.Cmd
  	Pid       int
  	StartTime time.Time
  	Dir       string
  	stdout    *bytes.Buffer
  	stderr    *bytes.Buffer
  }

  // ProcessResult contains the outcome of a completed subprocess.
  type ProcessResult struct {
  	ExitCode   int
  	Stdout     string
  	Stderr     string
  	DurationMs int64
  	Killed     bool
  }


id: process_result_go_infra
name: process_result
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["process","subprocess","result","exit","infra","exec"]
uses_types: []
file_path: functions/infra/process_handle.go
created_at: 2026-04-12T11:03:11Z
updated_at: 2026-04-12T11:03:11Z
content_hash: 1adb313383b5e41950a34d47adc360d299c4913b126d08dd8ec6d470bfecb430
definition:
  type ProcessResult struct {
      ExitCode   int
      Stdout     string
      Stderr     string
      DurationMs int64
      Killed     bool
  }

description:
  Resultado de un subproceso completado. Contiene codigo de salida, salida estandar y de error, duracion en milisegundos, y un flag que indica si fue terminado por timeout.

notes:
  Tipo producto — todos los campos siempre presentes. Killed=true indica que ProcessWait agoto el timeout y llamo a ProcessKill; en ese caso ExitCode suele ser -1 o el codigo de SIGKILL segun el OS. DurationMs incluye el tiempo total desde ProcessSpawn hasta que Wait() retorno.

code:
  package infra

  import (
  	"bytes"
  	"os/exec"
  	"time"
  )

  // ProcessHandle represents a running subprocess with output buffers.
  type ProcessHandle struct {
  	Cmd       *exec.Cmd
  	Pid       int
  	StartTime time.Time
  	Dir       string
  	stdout    *bytes.Buffer
  	stderr    *bytes.Buffer
  }

  // ProcessResult contains the outcome of a completed subprocess.
  type ProcessResult struct {
  	ExitCode   int
  	Stdout     string
  	Stderr     string
  	DurationMs int64
  	Killed     bool
  }


id: RateLimitConfig_go_infra
name: RateLimitConfig
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["rate_limit","config","http","middleware","infra"]
uses_types: []
file_path: functions/infra/rate_limiter.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
content_hash: 371b26d911646c348dcc8600c5192b1bf9fe110242845efce95956287ba3fc10
definition:
  type RateLimitConfig struct {
      RequestsPerSecond float64
      BurstSize         int
      KeyFunc           func(r *http.Request) string
      CleanupInterval   time.Duration
  }

description:
  Configuracion declarativa para construir un rate limiter HTTP. Agrupa tasa sostenida, rafaga maxima, extractor de clave y frecuencia de GC.

examples:
  ```go
  cfg := RateLimitConfig{
      RequestsPerSecond: 10,
      BurstSize:         20,
      KeyFunc:           func(r *http.Request) string { return r.Header.Get("X-API-Key") },
      CleanupInterval:   5 * time.Minute,
  }
  ```

notes:
  Estructura inmutable que parametriza un limiter. `KeyFunc` nil indica usar la IP del cliente. `CleanupInterval` 0 desactiva el GC automatico. El consumidor decide como inicializar el limiter a partir de esta config.

code:
  package infra

  import (
  	"net/http"
  	"sync"
  	"time"
  )

  // rateLimiterClient mantiene el estado de un bucket para una clave concreta.
  type rateLimiterClient struct {
  	tokens   float64
  	lastSeen time.Time
  }

  // RateLimiter mantiene el estado token-bucket de todos los clientes en memoria.
  // rate son los tokens recargados por segundo, burst es la capacidad maxima del bucket.
  // El campo clients es un mapa key -> bucket protegido por mu.
  type RateLimiter struct {
  	rate    float64
  	burst   int
  	mu      sync.Mutex
  	clients map[string]*rateLimiterClient
  }

  // RateLimitConfig parametriza el middleware de rate limiting.
  // KeyFunc extrae la clave del request (nil = IP del cliente).
  // CleanupInterval controla la frecuencia del GC de entries inactivas (0 = no GC).
  type RateLimitConfig struct {
  	RequestsPerSecond float64
  	BurstSize         int
  	KeyFunc           func(r *http.Request) string
  	CleanupInterval   time.Duration
  }

  // RateLimitResult es el resultado de evaluar un request contra el limiter.
  // Allowed indica si el request puede pasar.
  // Remaining son los tokens restantes en el bucket despues del check.
  // ResetAt es el momento en que el bucket vuelve a estar lleno.
  // RetryAfter son los segundos hasta que se pueda reintentar (0 si Allowed).
  type RateLimitResult struct {
  	Allowed    bool
  	Remaining  int
  	ResetAt    time.Time
  	RetryAfter float64
  }


id: RateLimitResult_go_infra
name: RateLimitResult
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["rate_limit","result","http","middleware","infra"]
uses_types: []
file_path: functions/infra/rate_limiter.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
content_hash: 4a5d5a3bce029c95e9c0a82eefb1fd10abf2b8082947f7ea00ed8f4d9a6fbbad
definition:
  type RateLimitResult struct {
      Allowed    bool
      Remaining  int
      ResetAt    time.Time
      RetryAfter float64
  }

description:
  Resultado de evaluar un request contra un RateLimiter. Indica si pasa, cuantos tokens quedan, cuando se rellena el bucket y cuanto esperar antes de reintentar.

examples:
  ```go
  result := RateLimiterCheck(rl, "192.168.1.1")
  if result.Allowed {
      // procesar request
  } else {
      w.Header().Set("Retry-After", strconv.FormatFloat(result.RetryAfter, 'f', 1, 64))
  }
  ```

notes:
  Snapshot inmutable del estado del bucket en el momento del check. `Remaining` es el numero entero de tokens disponibles tras el check. `ResetAt` es absoluto (UTC). `RetryAfter` es 0 cuando el request es permitido. Se serializa indirectamente a headers HTTP via `RateLimitHeaders`.

code:
  package infra

  import (
  	"net/http"
  	"sync"
  	"time"
  )

  // rateLimiterClient mantiene el estado de un bucket para una clave concreta.
  type rateLimiterClient struct {
  	tokens   float64
  	lastSeen time.Time
  }

  // RateLimiter mantiene el estado token-bucket de todos los clientes en memoria.
  // rate son los tokens recargados por segundo, burst es la capacidad maxima del bucket.
  // El campo clients es un mapa key -> bucket protegido por mu.
  type RateLimiter struct {
  	rate    float64
  	burst   int
  	mu      sync.Mutex
  	clients map[string]*rateLimiterClient
  }

  // RateLimitConfig parametriza el middleware de rate limiting.
  // KeyFunc extrae la clave del request (nil = IP del cliente).
  // CleanupInterval controla la frecuencia del GC de entries inactivas (0 = no GC).
  type RateLimitConfig struct {
  	RequestsPerSecond float64
  	BurstSize         int
  	KeyFunc           func(r *http.Request) string
  	CleanupInterval   time.Duration
  }

  // RateLimitResult es el resultado de evaluar un request contra el limiter.
  // Allowed indica si el request puede pasar.
  // Remaining son los tokens restantes en el bucket despues del check.
  // ResetAt es el momento en que el bucket vuelve a estar lleno.
  // RetryAfter son los segundos hasta que se pueda reintentar (0 si Allowed).
  type RateLimitResult struct {
  	Allowed    bool
  	Remaining  int
  	ResetAt    time.Time
  	RetryAfter float64
  }


id: RateLimiter_go_infra
name: RateLimiter
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["rate_limit","http","middleware","token_bucket","infra"]
uses_types: []
file_path: functions/infra/rate_limiter.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
content_hash: b05a655e59f5e53b8973d7974fd1f7d260909dafbda17e0edc39af4b52377ee7
definition:
  type RateLimiter struct {
      rate    float64
      burst   int
      mu      sync.Mutex
      clients map[string]*rateLimiterClient
  }

description:
  Token-bucket rate limiter in-memory. Mantiene un bucket por clave (IP o custom) con tokens recargados a tasa constante. Protegido por mutex para uso concurrente.

examples:
  ```go
  // 10 req/s con rafagas hasta 20
  rl := RateLimiterNew(10, 20)
  result := RateLimiterCheck(rl, "192.168.1.1")
  if !result.Allowed {
      // rechazar request
  }
  ```

notes:
  Tipo producto con estado mutable (mapa de clientes). Los campos son privados — se accede via `RateLimiterNew`, `RateLimiterCheck`, `RateLimiterCleanup`. El bucket se llena a `rate` tokens/segundo hasta un maximo de `burst`. Cada request consume 1 token. Sin GC explicito (`RateLimiterCleanup`) los buckets crecen indefinidamente con cada IP unica vista.

code:
  package infra

  import (
  	"net/http"
  	"sync"
  	"time"
  )

  // rateLimiterClient mantiene el estado de un bucket para una clave concreta.
  type rateLimiterClient struct {
  	tokens   float64
  	lastSeen time.Time
  }

  // RateLimiter mantiene el estado token-bucket de todos los clientes en memoria.
  // rate son los tokens recargados por segundo, burst es la capacidad maxima del bucket.
  // El campo clients es un mapa key -> bucket protegido por mu.
  type RateLimiter struct {
  	rate    float64
  	burst   int
  	mu      sync.Mutex
  	clients map[string]*rateLimiterClient
  }

  // RateLimitConfig parametriza el middleware de rate limiting.
  // KeyFunc extrae la clave del request (nil = IP del cliente).
  // CleanupInterval controla la frecuencia del GC de entries inactivas (0 = no GC).
  type RateLimitConfig struct {
  	RequestsPerSecond float64
  	BurstSize         int
  	KeyFunc           func(r *http.Request) string
  	CleanupInterval   time.Duration
  }

  // RateLimitResult es el resultado de evaluar un request contra el limiter.
  // Allowed indica si el request puede pasar.
  // Remaining son los tokens restantes en el bucket despues del check.
  // ResetAt es el momento en que el bucket vuelve a estar lleno.
  // RetryAfter son los segundos hasta que se pueda reintentar (0 si Allowed).
  type RateLimitResult struct {
  	Allowed    bool
  	Remaining  int
  	ResetAt    time.Time
  	RetryAfter float64
  }


id: Role_go_infra
name: Role
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["rbac","role","auth","infra"]
uses_types: ["Permission_go_infra"]
file_path: functions/infra/role.go
created_at: 2026-04-18T15:44:12Z
updated_at: 2026-04-18T15:44:12Z
content_hash: 6abdaac75aa3188834db25b9b724a8c1ae6fdefda7c3cd66a9cce5e978ff8e77
definition:
  type Role struct {
      Name        string       `json:"name"`
      Permissions []Permission `json:"permissions"`
  }

description:
  Rol RBAC con nombre y lista de permisos. Los roles son datos puros — cada app decide donde los almacena (hardcoded, JSON, SQLite).

examples:
  ```go
  roles := []Role{
      {
          Name: "admin",
          Permissions: []Permission{
              {Resource: "users", Action: "read"},
              {Resource: "users", Action: "write"},
              {Resource: "users", Action: "delete"},
          },
      },
      {
          Name: "viewer",
          Permissions: []Permission{
              {Resource: "users", Action: "read"},
          },
      },
  }
  ```

notes:
  Tipo producto — Name identifica el rol por nombre (coincide con el valor de `claims.Custom["role"]` para RBACMiddleware). Permissions es la lista completa de acciones que el rol puede realizar. No hay herencia entre roles: si admin debe tener todo lo de viewer, hay que incluir esos permisos explicitamente en la definicion de admin. Esta es una decision consciente para mantener la evaluacion lineal y predecible.

code:
  package infra

  // Role agrupa permisos bajo un nombre.
  // Ejemplo: {Name: "admin", Permissions: [{Resource:"users", Action:"delete"}, ...]}.
  type Role struct {
  	Name        string       `json:"name"`
  	Permissions []Permission `json:"permissions"`
  }


id: S3Config_go_infra
name: S3Config
lang: go
domain: infra
version: 1.0.0
algebraic: product
description: Configura la conexion a un almacenamiento S3-compatible (AWS S3, MinIO, Wasabi, etc).
tags: ["s3","storage","config","cloud","infra"]
uses_types: []
file_path: functions/infra/s3_config.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
content_hash: 83f5392f984015bad3c62d875e5a45fefcf060b4ad29c887f74b0819c680cd4e
definition:
  type S3Config struct {
      Endpoint  string
      Bucket    string
      AccessKey string
      SecretKey string
      Region    string
      UseSSL    bool
  }

examples:
  ```go
  cfg := S3Config{
      Endpoint:  "s3.amazonaws.com",
      Bucket:    "mi-bucket",
      AccessKey: os.Getenv("S3_ACCESS_KEY"),
      SecretKey: os.Getenv("S3_SECRET_KEY"),
      Region:    "us-east-1",
      UseSSL:    true,
  }
  ```

notes:
  Tipo producto. Para AWS S3 usar `Endpoint = "s3.amazonaws.com"` y la region apropiada. Para MinIO local usar `Endpoint = "minio.local:9000"` y `UseSSL = false`. Las credenciales NUNCA se hardcodean — se inyectan desde env vars o un secret manager.

code:
  package infra

  // S3Config configura la conexion a almacenamiento S3-compatible (AWS S3, MinIO, etc).
  type S3Config struct {
  	Endpoint  string `json:"endpoint"`   // URL del servidor (ej: "s3.amazonaws.com", "minio.local:9000")
  	Bucket    string `json:"bucket"`     // nombre del bucket
  	AccessKey string `json:"access_key"` // access key id
  	SecretKey string `json:"secret_key"` // secret access key
  	Region    string `json:"region"`     // region (ej: "us-east-1")
  	UseSSL    bool   `json:"use_ssl"`    // si true, usa https
  }


id: Session_go_infra
name: Session
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["session","auth","sqlite","token","infra"]
uses_types: []
file_path: functions/infra/session.go
created_at: 2026-04-18T15:44:12Z
updated_at: 2026-04-18T15:44:12Z
content_hash: e34ea6f4a6926b7e97f3a9556babb2a0d0e3610b2cbe4c72bb999f1204a72eec
definition:
  type Session struct {
      Token     string         `json:"token"`
      UserID    string         `json:"user_id"`
      ExpiresAt int64          `json:"expires_at"`
      CreatedAt int64          `json:"created_at"`
      Metadata  map[string]any `json:"metadata,omitempty"`
  }

description:
  Sesion de usuario almacenada en SQLite. Alternativa server-side a JWT para apps con estado. Token es 32 bytes hex opacos generados con crypto/rand.

examples:
  ```go
  session, _ := SessionCreate(db, "user-123", 24*time.Hour, map[string]any{
      "role":  "admin",
      "email": "alice@example.com",
  })
  // session.Token es el valor opaco a enviar al cliente
  w.Header().Set("X-Session-Token", session.Token)
  ```

notes:
  Tipo producto — Token identifica la sesion en la tabla SQLite. UserID vincula la sesion al usuario. ExpiresAt y CreatedAt son Unix epoch seconds. Metadata es un mapa libre que se serializa a JSON en la columna metadata de la tabla sessions. A diferencia de JWT, Session requiere una query a la BD para validar en cada request — a cambio permite invalidacion inmediata (DELETE FROM sessions WHERE token = ?).

code:
  package infra

  // Session representa una sesion de usuario almacenada en SQLite.
  // Token es un valor aleatorio opaco (32 bytes hex = 64 chars).
  // ExpiresAt y CreatedAt son Unix epoch seconds.
  type Session struct {
  	Token     string         `json:"token"`
  	UserID    string         `json:"user_id"`
  	ExpiresAt int64          `json:"expires_at"`
  	CreatedAt int64          `json:"created_at"`
  	Metadata  map[string]any `json:"metadata,omitempty"`
  }


id: SMTPConfig_go_infra
name: SMTPConfig
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["email","smtp","config","tls"]
uses_types: []
file_path: functions/infra/email_types.go
created_at: 2026-04-13T00:03:20Z
updated_at: 2026-04-13T00:03:20Z
content_hash: 4386e327933002fca2b3f7a0e1530ca7a837d310332c5f8057bbf2546803fcd4
definition:
  type SMTPConfig struct {
      Host     string
      Port     int
      Username string
      Password string
      TLSMode  string
  }

description:
  Parametros de conexion a un servidor SMTP. TLSMode controla el cifrado: 'tls' (port 465), 'starttls' (port 587) o '' sin cifrado (port 25).

examples:
  ```go
  cfg := SMTPConfig{
      Host:     "smtp.gmail.com",
      Port:     587,
      Username: "user@gmail.com",
      Password: "app-password",
      TLSMode:  "starttls",
  }
  ```

notes:
  Tipo producto. `TLSMode` debe ser `"tls"`, `"starttls"` o `""`. Por convencion: port 465 usa TLS directo, 587 usa STARTTLS, 25 sin cifrado. Para Gmail y proveedores modernos usar `"starttls"` en port 587 o `"tls"` en 465.

code:
  package infra

  // EmailAttachment represents a file attached to an email.
  type EmailAttachment struct {
  	Filename    string // nombre del archivo (ej: "report.pdf")
  	ContentType string // MIME type (ej: "application/pdf", "image/png")
  	Data        []byte // contenido binario del adjunto
  }

  // EmailMessage is an immutable email ready to send.
  // Build with EmailBuildHTML, EmailBuildText, or EmailWithAttachment.
  type EmailMessage struct {
  	From        string            // direccion del remitente (ej: "Alice <alice@example.com>")
  	To          []string          // destinatarios principales
  	CC          []string          // copia (puede ser nil)
  	BCC         []string          // copia oculta (puede ser nil)
  	Subject     string            // asunto del mensaje
  	BodyHTML    string            // cuerpo HTML (puede ser vacio si BodyText esta presente)
  	BodyText    string            // cuerpo texto plano (puede ser vacio si BodyHTML esta presente)
  	Attachments []EmailAttachment // adjuntos (puede ser nil)
  	Headers     map[string]string // headers MIME adicionales (ej: "X-Mailer": "fn_registry")
  }

  // SMTPConfig holds connection parameters for an SMTP server.
  // TLSMode controls the encryption strategy:
  //
  //	"tls"      — TLS directo (port 465)
  //	"starttls" — STARTTLS upgrade (port 587)
  //	""         — sin cifrado (port 25)
  type SMTPConfig struct {
  	Host     string // hostname del servidor SMTP (ej: "smtp.gmail.com")
  	Port     int    // puerto (465, 587, 25)
  	Username string // usuario/email de autenticacion
  	Password string // contrasena o app password
  	TLSMode  string // "tls", "starttls" o "" (sin cifrado)
  }


id: SSEEvent_go_infra
name: SSEEvent
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["sse","event","server-sent-events","infra","realtime"]
uses_types: []
file_path: functions/infra/sse_event.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
content_hash: d65916ca9767f0f715db73f900a3036ecbe157b3e64a22445f3b0c643933a07c
definition:
  type SSEEvent struct {
      Event string `json:"event"`
      Data  string `json:"data"`
      ID    string `json:"id"`
      Retry int    `json:"retry"`
  }

description:
  Evento Server-Sent Events segun la spec W3C. Campos opcionales: si Event esta vacio se envia solo data, si ID esta vacio no se incluye campo id, Retry en ms (0 = omitir y dejar el default del browser ~3000ms).

examples:
  ```go
  ev := SSEEvent{
      Event: "metrics_update",
      ID:    "42",
      Data:  `{"cpu": 23.4, "mem": 87.1}`,
  }
  SSESend(w, ev)
  ```

  Wire format generado:

  ```
  event: metrics_update
  id: 42
  data: {"cpu": 23.4, "mem": 87.1}

  ```

notes:
  Tipo producto con campos opcionales. `Data` puede contener saltos de linea — el formateador los traduce a multiples lineas `data:` segun la spec. `Retry` solo se envia si es > 0; cuando se envia, indica al cliente cuantos ms esperar antes de reconectar.

  Para enviar solo un comentario keepalive (no un evento), no se usa este tipo: se escribe `: keepalive\n\n` directamente al writer.

code:
  package infra

  // SSEEvent es un evento Server-Sent Events segun la spec W3C.
  // Campos opcionales: si Event esta vacio se envia solo data,
  // si ID esta vacio no se incluye campo id, Retry en ms (0 = omitir).
  type SSEEvent struct {
  	Event string `json:"event"`
  	Data  string `json:"data"`
  	ID    string `json:"id"`
  	Retry int    `json:"retry"`
  }


id: ssh_config_entry_go_infra
name: ssh_config_entry
lang: go
domain: infra
version: 1.0.0
algebraic: product
description: Bloque Host de ~/.ssh/config. Contiene alias, hostname, usuario, puerto, clave y opciones extra.
tags: ["ssh","config","remote","infra"]
uses_types: []
file_path: functions/infra/ssh_config_entry.go
created_at: 2026-04-08T21:06:03Z
updated_at: 2026-04-08T21:06:03Z
content_hash: 0a7b82bae8976f9a9c78dc1f5f1fbbad7a0c71b930c2ced1f05e3ef50c88863d
definition:
  type SSHConfigEntry struct {
      Alias        string
      HostName     string
      User         string
      Port         int
      IdentityFile string
      Options      map[string]string
  }

notes:
  Tipo producto — modela un bloque `Host` del archivo `~/.ssh/config`. Port=0 significa que no se especifica (el SSH client usa 22 por defecto). Options contiene pares clave-valor para directivas SSH adicionales como ForwardAgent, ProxyJump, ServerAliveInterval, etc.

  Incluye metodo `ToSSHConn()` para convertir a `SSHConn` y poder usar las funciones SSH operativas del registry (ssh_exec, ssh_check, etc.).

code:
  package infra

  // SSHConfigEntry representa un bloque Host en ~/.ssh/config.
  type SSHConfigEntry struct {
  	Alias        string            // Nombre del host (lo que va despues de "Host")
  	HostName     string            // IP o hostname real del servidor
  	User         string            // Usuario remoto
  	Port         int               // Puerto SSH (0 = no especificado, usa default 22)
  	IdentityFile string            // Ruta a clave privada
  	Options      map[string]string // Opciones SSH adicionales (ForwardAgent, ProxyJump, etc.)
  }

  // ToSSHConn convierte un SSHConfigEntry a SSHConn para usar con las funciones SSH del registry.
  func (e SSHConfigEntry) ToSSHConn() SSHConn {
  	return SSHConn{
  		Host:    e.HostName,
  		Port:    e.Port,
  		User:    e.User,
  		KeyPath: e.IdentityFile,
  	}
  }


id: ssh_conn_go_infra
name: ssh_conn
lang: go
domain: infra
version: 1.0.0
algebraic: product
description: Parametros de conexion SSH reutilizables. Contiene host, puerto, usuario y ruta a clave privada.
tags: ["ssh","connection","remote","infra"]
uses_types: []
file_path: functions/infra/ssh_conn.go
created_at: 2026-04-02T13:39:01Z
updated_at: 2026-04-02T13:39:01Z
content_hash: e4ee2945171d2a99273d2bf6606a08026e33cbd0b1f4439548ae022e252fa0a9
definition:
  type SSHConn struct {
      Host    string // Hostname o IP del servidor remoto
      Port    int    // Puerto SSH (0 usa el default 22)
      User    string // Usuario remoto
      KeyPath string // Ruta a clave privada (vacio usa ssh-agent o default)
  }

notes:
  Tipo producto — todos los campos siempre presentes. Port=0 se interpreta como puerto 22 por defecto. KeyPath vacio delega la autenticacion a ssh-agent o la clave default (~/.ssh/id_rsa). Incluye metodos helper (sshArgs, scpArgs, destination) que las funciones SSH del registry consumen internamente.

code:
  package infra

  import "fmt"

  // SSHConn parametros de conexion SSH reutilizables.
  type SSHConn struct {
  	Host    string // Hostname o IP del servidor remoto
  	Port    int    // Puerto SSH (0 usa el default 22)
  	User    string // Usuario remoto
  	KeyPath string // Ruta a clave privada (vacio usa ssh-agent o default)
  }

  // sshArgs construye los argumentos comunes de ssh/scp a partir de SSHConn.
  func (c SSHConn) sshArgs() []string {
  	var args []string
  	port := c.Port
  	if port == 0 {
  		port = 22
  	}
  	args = append(args, "-o", "StrictHostKeyChecking=accept-new")
  	args = append(args, "-p", fmt.Sprintf("%d", port))
  	if c.KeyPath != "" {
  		args = append(args, "-i", c.KeyPath)
  	}
  	return args
  }

  // scpArgs construye los argumentos comunes de scp a partir de SSHConn.
  func (c SSHConn) scpArgs() []string {
  	var args []string
  	port := c.Port
  	if port == 0 {
  		port = 22
  	}
  	args = append(args, "-o", "StrictHostKeyChecking=accept-new")
  	args = append(args, "-P", fmt.Sprintf("%d", port))
  	if c.KeyPath != "" {
  		args = append(args, "-i", c.KeyPath)
  	}
  	return args
  }

  // destination retorna user@host.
  func (c SSHConn) destination() string {
  	if c.User != "" {
  		return c.User + "@" + c.Host
  	}
  	return c.Host
  }


id: StorageConfig_go_infra
name: StorageConfig
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["storage","config","upload","file","infra"]
uses_types: []
file_path: functions/infra/storage_config.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
content_hash: 8984ecb42c679766adc45bc5d5a1342eac18776b65a034ffdf3d00503d50af21
definition:
  type StorageConfig struct {
      BaseDir      string
      MaxFileSize  int64
      AllowedTypes []string
  }

description:
  Configura el almacenamiento local de archivos: directorio base, tamano maximo y tipos MIME permitidos.

examples:
  ```go
  cfg := StorageConfig{
      BaseDir:      "./uploads",
      MaxFileSize:  10 << 20, // 10 MB
      AllowedTypes: []string{"image/jpeg", "image/png", "application/pdf"},
  }
  ```

notes:
  Tipo producto — todos los campos son obligatorios. `BaseDir` debe existir o ser creable por la app. `MaxFileSize` se aplica con `http.MaxBytesReader` durante el parse del multipart. `AllowedTypes` es una lista blanca verificada por magic bytes (no por el header Content-Type del request).

code:
  package infra

  // StorageConfig configura el almacenamiento local de archivos subidos.
  type StorageConfig struct {
  	BaseDir      string   `json:"base_dir"`      // directorio base para almacenar archivos
  	MaxFileSize  int64    `json:"max_file_size"` // tamano maximo en bytes (ej: 10<<20 = 10MB)
  	AllowedTypes []string `json:"allowed_types"` // MIME types permitidos (ej: ["image/png", "image/jpeg", "application/pdf"])
  }


id: UploadedFile_go_infra
name: UploadedFile
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["upload","file","storage","http","infra"]
uses_types: []
file_path: functions/infra/uploaded_file.go
created_at: 2026-04-18T15:33:38Z
updated_at: 2026-04-18T15:33:38Z
content_hash: b1ff5936eccefb0c341da5ed9540291fee3637018ed7baf5f5e0c8741769f437
definition:
  type UploadedFile struct {
      Filename    string    `json:"filename"`
      StoredName  string    `json:"stored_name"`
      Size        int64     `json:"size"`
      ContentType string    `json:"content_type"`
      Path        string    `json:"path"`
      CreatedAt   time.Time `json:"created_at"`
  }

description:
  Metadata de un archivo subido y almacenado. Combina nombre original, nombre unico en disco, tamano, MIME type, ruta y timestamp de creacion.

examples:
  ```go
  file := UploadedFile{
      Filename:    "vacaciones.png",
      StoredName:  "a1b2c3d4-e5f6-7890-abcd-ef1234567890.png",
      Size:        102400,
      ContentType: "image/png",
      Path:        "/var/uploads/a1b2c3d4-e5f6-7890-abcd-ef1234567890.png",
      CreatedAt:   time.Now(),
  }
  ```

notes:
  Tipo producto — todos los campos siempre presentes. `Filename` se preserva por trazabilidad pero NO se usa como nombre real en disco (riesgo path traversal). `StoredName` es generado con UUID por `file_unique_name`. `Path` es la ruta completa en disco para storage local, o la key del objeto para S3.

code:
  package infra

  import "time"

  // UploadedFile contiene la metadata de un archivo subido y almacenado en disco o S3.
  type UploadedFile struct {
  	Filename    string    `json:"filename"`     // nombre original del archivo
  	StoredName  string    `json:"stored_name"`  // nombre en disco (UUID-based)
  	Size        int64     `json:"size"`         // tamano en bytes
  	ContentType string    `json:"content_type"` // MIME type detectado
  	Path        string    `json:"path"`         // ruta completa en disco (o key S3)
  	CreatedAt   time.Time `json:"created_at"`   // timestamp de creacion
  }


id: WSClient_go_infra
name: WSClient
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["websocket","client","connection","server","infra","realtime"]
uses_types: ["WSHub_go_infra"]
file_path: functions/infra/ws_client.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
content_hash: 97db54c71d57c0cd6de949a5c17020f54a95eef573a23115596db89e8876c13e
definition:
  type WSClient struct {
      Hub  *WSHub
      Conn *websocket.Conn
      Send chan []byte
      ID   string
  }

description:
  Conexion WebSocket individual gestionada por un WSHub. Cada cliente tiene su propio canal Send buffereado para entregar mensajes en orden y un ID para identificarlo en broadcasts y handlers.

examples:
  ```go
  client := &WSClient{
      Hub:  hub,
      Conn: wsConn,
      Send: make(chan []byte, 64),
      ID:   "user-42",
  }
  hub.Register <- client
  ```

notes:
  Tipo producto. El canal `Send` debe ser buffereado (tipico 64-256) para evitar que un cliente lento bloquee el broadcast del hub. `Conn` usa `nhooyr.io/websocket` que soporta `context.Context` nativamente. `ID` es texto libre — la app que monta el handler decide su semantica (UUID, username, session id, etc.).

  Cada cliente tiene normalmente dos goroutines internas:
  - readPump: lee del Conn y publica al hub.Broadcast (o procesa con callback)
  - writePump: consume del Send y escribe al Conn

code:
  package infra

  import (
  	"nhooyr.io/websocket"
  )

  // WSClient representa una conexion WebSocket individual.
  // Cada cliente tiene su propio canal de envio buffereado y una
  // referencia al hub al que pertenece.
  type WSClient struct {
  	Hub  *WSHub
  	Conn *websocket.Conn
  	Send chan []byte
  	ID   string
  }


id: WSHub_go_infra
name: WSHub
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["websocket","hub","server","broadcast","infra","realtime"]
uses_types: ["WSClient_go_infra"]
file_path: functions/infra/ws_hub.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
content_hash: 65daf1a0d02e04115e0061e7cf78755fa0d5b63f661af56282734a61bd520f43
definition:
  type WSHub struct {
      Clients    map[*WSClient]bool
      Broadcast  chan []byte
      Register   chan *WSClient
      Unregister chan *WSClient
  }

description:
  Hub que gestiona el ciclo de vida de conexiones WebSocket. Mantiene un mapa de clientes activos y canales para registro, desregistro y broadcast. Se ejecuta como goroutine via Run() y se compone con ws_handler para servir conexiones.

examples:
  ```go
  hub := NewWSHub()
  go hub.Run()
  defer hub.Stop()

  routes := []Route{
      {Method: "GET", Path: "/ws", Handler: WSHandler(hub, []string{"*"})},
  }
  ```

notes:
  Tipo producto. El campo `done` interno (no exportado) controla el cierre limpio. `Broadcast` es buffereado (256) para no bloquear emisores. Cada cliente lento se desconecta automaticamente si su canal `Send` se llena durante un broadcast — esta es la garantia anti-backpressure: un cliente que no consume no afecta a los demas.

  El loop `Run()` es de un solo thread escribiendo al mapa, asi que no hace falta mutex. Para parar limpiamente: `hub.Stop()` cierra `done`, libera todos los `client.Send` y termina el loop.

code:
  package infra

  import "sync/atomic"

  // WSHub gestiona el ciclo de vida de conexiones WebSocket.
  // Mantiene un mapa de clientes activos y canales para registro,
  // desregistro y broadcast. Se ejecuta como goroutine via su Run().
  type WSHub struct {
  	Clients    map[*WSClient]bool
  	Broadcast  chan []byte
  	Register   chan *WSClient
  	Unregister chan *WSClient
  	done       chan struct{}
  	count      atomic.Int64
  }

  // ClientCount retorna el numero de clientes registrados de forma thread-safe.
  // Util para metricas, dashboards y tests.
  func (h *WSHub) ClientCount() int {
  	return int(h.count.Load())
  }

  // NewWSHub crea un WSHub vacio con canales inicializados.
  // Ejecutar Run() en una goroutine para activar el loop de eventos.
  func NewWSHub() *WSHub {
  	return &WSHub{
  		Clients:    make(map[*WSClient]bool),
  		Broadcast:  make(chan []byte, 256),
  		Register:   make(chan *WSClient),
  		Unregister: make(chan *WSClient),
  		done:       make(chan struct{}),
  	}
  }

  // Run ejecuta el loop principal del hub. Atiende registros, desregistros
  // y broadcasts en un select. Bloqueante: lanzar como goroutine.
  // Para parar, llamar a Stop().
  func (h *WSHub) Run() {
  	for {
  		select {
  		case <-h.done:
  			// Cerrar todos los Send y vaciar el mapa
  			for client := range h.Clients {
  				delete(h.Clients, client)
  				close(client.Send)
  			}
  			h.count.Store(0)
  			return
  		case client := <-h.Register:
  			h.Clients[client] = true
  			h.count.Store(int64(len(h.Clients)))
  		case client := <-h.Unregister:
  			if _, ok := h.Clients[client]; ok {
  				delete(h.Clients, client)
  				close(client.Send)
  				h.count.Store(int64(len(h.Clients)))
  			}
  		case msg := <-h.Broadcast:
  			for client := range h.Clients {
  				select {
  				case client.Send <- msg:
  				default:
  					// Cliente lento: desconectar
  					delete(h.Clients, client)
  					close(client.Send)
  				}
  			}
  			h.count.Store(int64(len(h.Clients)))
  		}
  	}
  }

  // Stop cierra el loop de Run() y limpia todos los clientes.
  // Idempotente — llamar mas de una vez no panica.
  func (h *WSHub) Stop() {
  	select {
  	case <-h.done:
  		// Ya cerrado
  	default:
  		close(h.done)
  	}
  }


id: WSMessage_go_infra
name: WSMessage
lang: go
domain: infra
version: 1.0.0
algebraic: product
tags: ["websocket","message","protocol","infra","realtime"]
uses_types: []
file_path: functions/infra/ws_message.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
content_hash: f31d4d1785ff6d5906c4c5245fbf1a980d716bf2abadce0832632827cd1d91cf
definition:
  type WSMessage struct {
      Type     string `json:"type"`
      Payload  []byte `json:"payload"`
      SenderID string `json:"sender_id"`
      Ts       int64  `json:"ts"`
  }

description:
  Mensaje tipado que viaja por WebSocket entre cliente y servidor. El campo Type permite al receptor decidir como procesar el payload. Incluye SenderID y timestamp para trazabilidad.

examples:
  ```go
  msg := WSMessage{
      Type:     "chat",
      Payload:  []byte(`{"text":"hola"}`),
      SenderID: "user-1",
      Ts:       time.Now().UnixMilli(),
  }
  data, _ := json.Marshal(msg)
  WSBroadcast(hub, data)
  ```

notes:
  Tipo producto. Las tags `json:` permiten serializar directamente a JSON para enviar por el wire. `Payload` es `[]byte` para permitir tanto JSON anidado como datos binarios codificados en base64. `Ts` en milisegundos epoch para compatibilidad con `Date.now()` en el browser.

  No es obligatorio usar este tipo — apps que necesiten un protocolo distinto pueden enviar bytes arbitrarios via `WSBroadcast` o `WSSend`. Es solo un convenio recomendado para protocolos chat-like.

code:
  package infra

  // WSMessage es un mensaje tipado que viaja por WebSocket.
  // El campo Type permite al receptor decidir como procesar el payload.
  type WSMessage struct {
  	Type     string `json:"type"`
  	Payload  []byte `json:"payload"`
  	SenderID string `json:"sender_id"`
  	Ts       int64  `json:"ts"`
  }


id: cmd_result_go_shell
name: cmd_result
lang: go
domain: shell
version: 1.0.0
algebraic: product
definition:
  type CmdResult struct {
  Stdout string
  Stderr string
  ExitCode int
  }

description: Resultado de la ejecucion de un comando del sistema con stdout, stderr y codigo de salida.
tags: ["shell","command","process","result"]
uses_types: []
file_path: functions/shell/cmd_result.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
notes: Tipo producto. Metodos Output(), Lines(), Success(), ToResult() disponibles sobre la instancia.
content_hash: 490ac14f0bd4cf39e46514120bb444e51a24ec6fa981c709655fd01ca993f193
code:
  package shell

  // CmdResult almacena el resultado de ejecutar un comando del sistema.
  // Implementation: github.com/lucasdataproyects/devfactory/shell
  type CmdResult struct {
  	Stdout   string
  	Stderr   string
  	ExitCode int
  }


id: base_model_go_tui
name: base_model
lang: go
domain: tui
version: 1.0.0
algebraic: product
definition: type BaseModel struct { Width int; Height int; Styles Styles; Err error }
tags: ["tui","base","model","component"]
uses_types: ["styles_go_tui"]
file_path: functions/tui/base_model.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 4f5b78d0f96ce166d84bac22b92f37ef6e31469a3296fcee89fa6f713a14e0e2
description:
  Modelo base que provee dimensiones de terminal, estilos y manejo de errores comunes a todas las vistas TUI.

code:
  package tui

  // BaseModel provee funcionalidad comun a todas las vistas TUI:
  // dimensiones de terminal, estilos y manejo de errores.
  // Implementation: github.com/lucasdataproyects/devfactory/tui
  type BaseModel struct {
  	Width  int
  	Height int
  	Styles Styles
  	Err    error
  }


id: confirm_model_go_tui
name: confirm_model
lang: go
domain: tui
version: 1.0.0
algebraic: product
definition: type ConfirmModel struct { BaseModel; prompt string; selected bool; done bool }
description: Dialogo de confirmacion Si/No interactivo. Embeds BaseModel. Implementa tea.Model.
tags: ["tui","confirm","dialog","component","interactive"]
uses_types: ["base_model_go_tui"]
file_path: functions/tui/confirm_model.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: fe1838a87d9fd34620b294cf9eb14f740989cab5d4f19617aa1427fd328f7507
code:
  package tui

  // ConfirmModel es un dialogo de confirmacion Si/No interactivo.
  // Implementa tea.Model del framework Bubble Tea.
  // Implementation: github.com/lucasdataproyects/devfactory/tui
  type ConfirmModel struct {
  	BaseModel
  	prompt   string
  	selected bool
  	done     bool
  }


id: filtered_list_model_go_tui
name: filtered_list_model
lang: go
domain: tui
version: 1.0.0
algebraic: product
definition: type FilteredListModel struct { ListModel; query string; allItems []ListItem; placeholder string }
description: Lista con filtrado por texto en tiempo real. Embeds ListModel y añade busqueda interactiva.
tags: ["tui","list","filter","component","interactive"]
uses_types: ["list_model_go_tui","list_item_go_tui"]
file_path: functions/tui/filtered_list_model.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: f175db0fdb7ac4ee00a099d61f51cf3fb9d0cd3fbf8819888b79cb5417f2973b
code:
  package tui

  // FilteredListModel extiende ListModel con filtrado por texto en tiempo real.
  // Implementation: github.com/lucasdataproyects/devfactory/tui
  type FilteredListModel struct {
  	ListModel
  	query       string
  	allItems    []ListItem
  	placeholder string
  }


id: list_item_go_tui
name: list_item
lang: go
domain: tui
version: 1.0.0
algebraic: product
definition: type ListItem struct { Title string; Description string; Value interface{} }
description: Item individual de una lista TUI con titulo, descripcion y valor arbitrario.
tags: ["tui","list","component"]
uses_types: []
file_path: functions/tui/list_item.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: a856fc54dcfeb26a4bcb06e758ae1192c3c395cf728620a40d95180aec150fc0
code:
  package tui

  // ListItem representa un item individual en una lista TUI.
  // Implementation: github.com/lucasdataproyects/devfactory/tui
  type ListItem struct {
  	Title       string
  	Description string
  	Value       interface{}
  }


id: list_model_go_tui
name: list_model
lang: go
domain: tui
version: 1.0.0
algebraic: product
tags: ["tui","list","component","interactive"]
uses_types: ["list_item_go_tui","styles_go_tui"]
file_path: functions/tui/list_model.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: a435ed9fe29c4472548effd2f519b759c1e4fbc136a5ac1347a1ebcc273db818
definition:
  type ListModel struct { items []ListItem; cursor int; selected map[int]struct{}; multi bool; styles Styles; height int; width int; offset int }

description:
  Componente lista seleccionable con cursor, scroll y seleccion simple o multiple. Implementa tea.Model.

code:
  package tui

  // ListModel es un componente lista seleccionable con cursor, scroll y seleccion.
  // Implementa tea.Model del framework Bubble Tea.
  // Implementation: github.com/lucasdataproyects/devfactory/tui
  type ListModel struct {
  	items    []ListItem
  	cursor   int
  	selected map[int]struct{}
  	multi    bool
  	styles   Styles
  	height   int
  	width    int
  	offset   int
  }


id: multi_progress_model_go_tui
name: multi_progress_model
lang: go
domain: tui
version: 1.0.0
algebraic: product
definition: type MultiProgressModel struct { bars []ProgressModel; styles Styles }
description: Gestor de multiples barras de progreso simultaneas. Implementa tea.Model.
tags: ["tui","progress","multi","component"]
uses_types: ["progress_model_go_tui","styles_go_tui"]
file_path: functions/tui/multi_progress_model.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 128e467fcf0d420b179e1e4ad79b4b7f5d66e8cb606912d1dbe2e314d73db5ff
code:
  package tui

  // MultiProgressModel gestiona multiples barras de progreso simultaneas.
  // Implementa tea.Model del framework Bubble Tea.
  // Implementation: github.com/lucasdataproyects/devfactory/tui
  type MultiProgressModel struct {
  	bars   []ProgressModel
  	styles Styles
  }


id: progress_model_go_tui
name: progress_model
lang: go
domain: tui
version: 1.0.0
algebraic: product
description: Barra de progreso con porcentaje, ETA y tiempo transcurrido. Implementa tea.Model.
tags: ["tui","progress","component","interactive"]
uses_types: ["styles_go_tui"]
file_path: functions/tui/progress_model.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 0d6c24f08e6e117922f36dd8a86aae3c3dcb16e02ed371cebace25bcf884598b
definition:
  type ProgressModel struct { progress progress.Model; current float64; total float64; label string; styles Styles; showPct bool; showETA bool; started time.Time }

code:
  package tui

  import (
  	"time"

  	"github.com/charmbracelet/bubbles/progress"
  )

  // ProgressModel es una barra de progreso con porcentaje, ETA y tiempo transcurrido.
  // Implementa tea.Model del framework Bubble Tea.
  // Implementation: github.com/lucasdataproyects/devfactory/tui
  type ProgressModel struct {
  	progress progress.Model
  	current  float64
  	total    float64
  	label    string
  	styles   Styles
  	showPct  bool
  	showETA  bool
  	started  time.Time
  }


id: spinner_model_go_tui
name: spinner_model
lang: go
domain: tui
version: 1.0.0
algebraic: product
description: Indicador de carga animado con mensaje personalizable. Implementa tea.Model.
tags: ["tui","spinner","loading","component"]
uses_types: []
file_path: functions/tui/spinner_model.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: ccd5eabc0e52ad5d3b2d2d0f0871daf8610f6e5cbd98c7e27f37d251e2969d43
definition:
  type SpinnerModel struct { spinner spinner.Model; message string; style lipgloss.Style; active bool }

code:
  package tui

  import (
  	"github.com/charmbracelet/bubbles/spinner"
  	"github.com/charmbracelet/lipgloss"
  )

  // SpinnerModel es un indicador de carga animado con mensaje personalizable.
  // Implementa tea.Model del framework Bubble Tea.
  // Implementation: github.com/lucasdataproyects/devfactory/tui
  type SpinnerModel struct {
  	spinner spinner.Model
  	message string
  	style   lipgloss.Style
  	active  bool
  }


id: spinner_with_timeout_model_go_tui
name: spinner_with_timeout_model
lang: go
domain: tui
version: 1.0.0
algebraic: product
description: Spinner que se auto-detiene tras un timeout configurable. Embeds SpinnerModel.
tags: ["tui","spinner","timeout","component"]
uses_types: ["spinner_model_go_tui"]
file_path: functions/tui/spinner_with_timeout_model.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 4ef161de02fc5703fbe927151482f498d3623617ae300a7c769fc9f9cc78c9a4
definition:
  type SpinnerWithTimeoutModel struct { SpinnerModel; timeout time.Duration; started time.Time; finished bool }

code:
  package tui

  import "time"

  // SpinnerWithTimeoutModel es un spinner que se auto-detiene tras un timeout.
  // Implementation: github.com/lucasdataproyects/devfactory/tui
  type SpinnerWithTimeoutModel struct {
  	SpinnerModel
  	timeout  time.Duration
  	started  time.Time
  	finished bool
  }


id: styles_go_tui
name: styles
lang: go
domain: tui
version: 1.0.0
algebraic: product
tags: ["tui","styles","lipgloss","theme"]
uses_types: ["theme_go_tui"]
file_path: functions/tui/styles.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: 2041e60650c74fd9f981f4b14838f2d26167c4a33ffbaafbd9aeeb02435fa997
definition:
  type Styles struct { Theme Theme; Title lipgloss.Style; Subtitle lipgloss.Style; Header lipgloss.Style; Label lipgloss.Style; Text lipgloss.Style; Muted lipgloss.Style; Code lipgloss.Style; Success lipgloss.Style; Error lipgloss.Style; Warning lipgloss.Style; Info lipgloss.Style; Border lipgloss.Style; Box lipgloss.Style; List lipgloss.Style; ListItem lipgloss.Style; Selected lipgloss.Style; Unselected lipgloss.Style; Button lipgloss.Style; ButtonActive lipgloss.Style; Input lipgloss.Style; Cursor lipgloss.Style; Container lipgloss.Style; Section lipgloss.Style; Divider lipgloss.Style; StatusBar lipgloss.Style }

description:
  Coleccion completa de estilos lipgloss pre-configurados para tipografia, estados, componentes y layout.

code:
  package tui

  import "github.com/charmbracelet/lipgloss"

  // Styles contiene todos los estilos lipgloss pre-configurados para una aplicacion TUI.
  // Implementation: github.com/lucasdataproyects/devfactory/tui
  type Styles struct {
  	Theme        Theme
  	Title        lipgloss.Style
  	Subtitle     lipgloss.Style
  	Header       lipgloss.Style
  	Label        lipgloss.Style
  	Text         lipgloss.Style
  	Muted        lipgloss.Style
  	Code         lipgloss.Style
  	Success      lipgloss.Style
  	Error        lipgloss.Style
  	Warning      lipgloss.Style
  	Info         lipgloss.Style
  	Border       lipgloss.Style
  	Box          lipgloss.Style
  	List         lipgloss.Style
  	ListItem     lipgloss.Style
  	Selected     lipgloss.Style
  	Unselected   lipgloss.Style
  	Button       lipgloss.Style
  	ButtonActive lipgloss.Style
  	Input        lipgloss.Style
  	Cursor       lipgloss.Style
  	Container    lipgloss.Style
  	Section      lipgloss.Style
  	Divider      lipgloss.Style
  	StatusBar    lipgloss.Style
  }


id: theme_go_tui
name: theme
lang: go
domain: tui
version: 1.0.0
algebraic: product
description: Paleta de colores para terminal con 9 colores semanticos. Base del sistema de estilos.
tags: ["tui","theme","styles","colors"]
uses_types: []
file_path: functions/tui/theme.go
created_at: 2026-04-01T01:37:51Z
updated_at: 2026-04-01T01:37:51Z
content_hash: df55a02e9b36963af9125444666bcfa0bb211283f2be0ba4d8252b6f7ca73d93
definition:
  type Theme struct { Primary lipgloss.Color; Secondary lipgloss.Color; Success lipgloss.Color; Error lipgloss.Color; Warning lipgloss.Color; Info lipgloss.Color; Muted lipgloss.Color; Text lipgloss.Color; Border lipgloss.Color }

code:
  package tui

  import "github.com/charmbracelet/lipgloss"

  // Theme define una paleta de 9 colores semanticos para terminal.
  // Implementation: github.com/lucasdataproyects/devfactory/tui
  type Theme struct {
  	Primary   lipgloss.Color
  	Secondary lipgloss.Color
  	Success   lipgloss.Color
  	Error     lipgloss.Color
  	Warning   lipgloss.Color
  	Info      lipgloss.Color
  	Muted     lipgloss.Color
  	Text      lipgloss.Color
  	Border    lipgloss.Color
  }


id: GraphData_cpp_viz
name: GraphData
lang: cpp
domain: viz
version: 1.0.0
algebraic: product
tags: ["graph","network","visualization","gpu","force-layout","node","edge","imgui"]
uses_types: []
file_path: cpp/functions/viz/graph_types.h
created_at: 2026-04-07T22:00:02Z
updated_at: 2026-04-07T22:00:02Z
content_hash: d8a6fa4625361ba2e2fd95e9887acccd53eb8c9f559473295d2fbfafb4a17a11
definition:
  struct GraphNode {
      uint32_t id;
      float x, y;
      float vx, vy;
      float size;
      uint32_t color;
      const char* label;
      uint32_t community;
      float value;
      bool pinned;
  };

  struct GraphEdge {
      uint32_t source;
      uint32_t target;
      float weight;
      uint32_t color;
  };

  struct GraphData {
      GraphNode* nodes;
      int node_count;
      GraphEdge* edges;
      int edge_count;
      float min_x, min_y, max_x, max_y;
      void update_bounds();
      int find_node(uint32_t id) const;
  };

description:
  Tipos de datos base para el sistema de grafos GPU del registry. GraphNode modela un vertice con posicion, velocidad, apariencia y metadatos de layout. GraphEdge modela una arista con peso y color. GraphData es el contenedor principal que agrupa nodos y aristas con bounding box y metodos de consulta. Disenado para integrarse con force-directed layout y renderizado GPU via ImGui/ImPlot.

notes:
  - La memoria de `nodes` y `edges` es propiedad del caller. `GraphData` no hace `new`/`delete`.
  - `color` usa formato ABGR packed (compatible con ImGui `ImU32`): `0xAABBGGRR`.
  - Las aristas referencian por indice, no por id, para que el loop de simulacion sea cache-friendly.
  - `community` con valor 0 se interpreta como "sin grupo" — los colores de comunidad empiezan desde 1.

documentation:
  ## Structs

  ### GraphNode

  Vertice del grafo. Contiene todos los datos necesarios para el layout y el renderizado.

  | Campo | Tipo | Descripcion |
  |---|---|---|
  | `id` | `uint32_t` | Identificador unico del nodo |
  | `x, y` | `float` | Posicion en el espacio de layout |
  | `vx, vy` | `float` | Velocidad del nodo, usada por el algoritmo force-directed |
  | `size` | `float` | Radio visual en pixels (por defecto 4.0) |
  | `color` | `uint32_t` | Color en formato ABGR packed. 0 = usar paleta automatica por community |
  | `label` | `const char*` | Etiqueta visible. `nullptr` = sin etiqueta |
  | `community` | `uint32_t` | ID de grupo/cluster para auto-coloreo. 0 = sin grupo |
  | `value` | `float` | Metrica arbitraria (puede usarse para escalar el tamaño del nodo) |
  | `pinned` | `bool` | Si es `true`, el force layout no mueve este nodo |

  ### GraphEdge

  Arista del grafo. Referencia nodos por indice (no por id) para acceso O(1) en el loop de simulacion.

  | Campo | Tipo | Descripcion |
  |---|---|---|
  | `source` | `uint32_t` | Indice en `GraphData::nodes` del nodo origen |
  | `target` | `uint32_t` | Indice en `GraphData::nodes` del nodo destino |
  | `weight` | `float` | Peso de la arista. Afecta la fuerza de atraccion en el layout |
  | `color` | `uint32_t` | Color ABGR packed. 0 = gris por defecto |

  ### GraphData

  Contenedor principal. Posee los arrays de nodos y aristas (memoria gestionada externamente). Mantiene un bounding box actualizable para proyeccion de coordenadas a pantalla.

  | Campo/Metodo | Descripcion |
  |---|---|
  | `nodes` / `node_count` | Array de nodos y su longitud |
  | `edges` / `edge_count` | Array de aristas y su longitud |
  | `min_x, min_y, max_x, max_y` | Bounding box calculado sobre las posiciones actuales |
  | `update_bounds()` | Recalcula el bounding box iterando todos los nodos |
  | `find_node(id)` | Busqueda lineal por `GraphNode::id`. Retorna -1 si no existe |

  ## Helpers

  ```cpp
  // Crear un nodo con valores por defecto
  GraphNode n = graph_node(42, 100.0f, 200.0f);

  // Crear una arista con peso por defecto 1.0
  GraphEdge e = graph_edge(0, 1, 2.5f);
  ```

  ## Implementacion

  Los metodos `update_bounds()` y `find_node()` estan implementados en `cpp/functions/viz/graph_types.cpp`.

  `update_bounds()` es O(n) sobre `node_count`. Llamar despues de cada step del layout para mantener el bounding box fresco.

  `find_node()` es O(n) por busqueda lineal. Para grafos grandes (>10k nodos) considerar mantener un `unordered_map<uint32_t, int>` externo como indice.

code:
  #pragma once
  #include <cstdint>

  // --- Graph node ---
  struct GraphNode {
      uint32_t id;
      float x, y;            // position in layout space
      float vx, vy;          // velocity (used by force layout)
      float size;             // visual radius (default 4.0)
      uint32_t color;         // ABGR packed (0 = use default palette)
      const char* label;      // optional display label (nullptr = none)
      uint32_t community;     // group/cluster ID (for auto-coloring)
      float value;            // arbitrary metric (for sizing)
      bool pinned;            // if true, force layout won't move this node
  };

  // --- Graph edge ---
  struct GraphEdge {
      uint32_t source;        // index into GraphData::nodes
      uint32_t target;        // index into GraphData::nodes
      float weight;           // edge weight (affects attraction force)
      uint32_t color;         // ABGR packed (0 = default gray)
  };

  // --- Graph container ---
  struct GraphData {
      GraphNode* nodes;
      int node_count;
      GraphEdge* edges;
      int edge_count;

      // Bounding box (updated by layout)
      float min_x, min_y, max_x, max_y;

      // Recompute bounding box from node positions
      void update_bounds();

      // Find node index by id. Returns -1 if not found.
      int find_node(uint32_t id) const;
  };

  // --- Helper: create a default node ---
  inline GraphNode graph_node(uint32_t id, float x = 0, float y = 0) {
      return {id, x, y, 0, 0, 4.0f, 0, nullptr, 0, 0, false};
  }

  // --- Helper: create an edge ---
  inline GraphEdge graph_edge(uint32_t source, uint32_t target, float weight = 1.0f) {
      return {source, target, weight, 0};
  }


id: APIClientConfig_ts_core
name: APIClientConfig
lang: ts
domain: core
version: 1.0.0
algebraic: product
tags: ["api","client","config","http","fetch","auth"]
uses_types: []
file_path: frontend/types/core/api_client_config.ts
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: bacf1f04891a62b7055156bf98457f4c41384b7382dcb54d743b89e6b6b332a3
definition:
  interface APIClientConfig {
    base_url: string
    headers?: Record<string, string>
    on_error?: (error: Error, url: string, init?: RequestInit) => void
    on_unauthorized?: () => void
  }

description:
  Configuracion del cliente HTTP. Define base_url, headers fijos, callback de error global y callback de 401 Unauthorized.

notes:
  Pasado como primer argumento a `api_client_ts_core`. Los interceptors `on_error` y `on_unauthorized` son globales — aplican a todos los metodos (get, post, put, del, patch) del cliente creado.

documentation:
  ## Campos

  - `base_url` — prefijo de URL para todos los requests, sin slash final (ej: `"http://localhost:8484/api"`)
  - `headers` — headers HTTP fijos que se mergeaan en cada peticion (ej: `{ Authorization: 'Bearer ...' }`)
  - `on_error` — callback global llamado cuando cualquier peticion falla, recibe el error, la URL y el RequestInit
  - `on_unauthorized` — callback llamado cuando el servidor responde 401, tipicamente redirige al login

  ## Uso

  ```typescript
  import type { APIClientConfig } from '@/types/core/api_client_config'
  import { apiClient } from '@/functions/core/api_client'

  const config: APIClientConfig = {
    base_url: 'http://localhost:8484/api',
    headers: { 'X-Client': 'dashboard' },
    on_unauthorized: () => (window.location.href = '/login'),
  }
  const api = apiClient(config)
  ```

code:
  /** Configuracion del cliente HTTP creado por api_client. */
  export interface APIClientConfig {
    /** URL base para todas las peticiones (ej: "http://localhost:8484/api"). Sin slash final. */
    base_url: string
    /** Headers fijos que se incluyen en todas las peticiones */
    headers?: Record<string, string>
    /** Callback global de error — llamado en cualquier peticion fallida */
    on_error?: (error: Error, url: string, init?: RequestInit) => void
    /** Callback llamado cuando el servidor responde 401 Unauthorized */
    on_unauthorized?: () => void
  }


id: ComponentVariants_ts_core
name: ComponentVariants
lang: ts
domain: core
version: 1.0.0
algebraic: product
description: Tipos base para componentes con variantes CVA. Props comunes y composición de variantes type-safe.
tags: ["component","variants","cva","props","base"]
uses_types: []
file_path: frontend/types/core/component_variants.ts
created_at: 2026-04-03T01:01:56Z
updated_at: 2026-04-06T00:11:54Z
code:
  export interface ComponentBaseProps {
  className?: string
  children?: React.ReactNode
  }

content_hash: 22c4be3096963da8df3a3c251d0c31fdb688031935d5d300f2450c29eccb894a
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/button.tsx
definition:
  interface ComponentBaseProps { className?: string; children?: React.ReactNode }
  type PropsWithVariants<V> = ComponentBaseProps & VariantProps<V>

notes:
  Requiere `class-variance-authority` como dependencia del proyecto consumidor. `React.ReactNode` requiere que `react` esté en el scope — en proyectos con `@types/react` esto se resuelve automáticamente.

documentation:
  ## Tipos exportados

  - `VariantProps<V>` — re-exportado de `class-variance-authority`. Extrae el tipo de props de un `cva()` call.
  - `ComponentBaseProps` — props comunes a todos los componentes: `className` y `children` opcionales.
  - `PropsWithVariants<V>` — combinación de `ComponentBaseProps` con las variantes de un `cva()` concreto. Usar como base para las props de cualquier componente con variantes.

  ## Uso

  ```typescript
  import { cva } from "class-variance-authority"
  import { type PropsWithVariants } from "@/types/core/component_variants"

  const buttonVariants = cva("base-classes", {
    variants: { size: { sm: "...", md: "..." } }
  })

  type ButtonProps = PropsWithVariants<typeof buttonVariants>
  ```


id: FetchState_ts_core
name: FetchState
lang: ts
domain: core
version: 1.0.0
algebraic: product
tags: ["fetch","state","http","cache","swr","hook"]
uses_types: []
file_path: frontend/types/core/fetch_state.ts
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: c74410a98d66c3c08552dce86f1a600d473c8cd01f8f2ddf7e678b358a9918a1
definition:
  interface FetchState<T> {
    data: T | null
    error: Error | null
    loading: boolean
    refetch: () => Promise<T>
    is_stale: boolean
  }

description:
  Estado de un fetch GET con cache stale-while-revalidate. Incluye datos, error, loading, refetch y flag de staleness.

notes:
  Retornado por `use_fetch_ts_core`. El patron stale-while-revalidate permite mostrar datos viejos inmediatamente mientras se actualizan en background, mejorando la UX percibida.

documentation:
  ## Campos

  - `data` — datos obtenidos, null hasta que llega la primera respuesta
  - `error` — error de red o de parsing, null si no hubo error
  - `loading` — true mientras hay una peticion en vuelo
  - `refetch` — funcion para re-ejecutar la peticion manualmente
  - `is_stale` — true cuando se muestran datos del cache mientras se refetchea en background

  ## Uso

  ```typescript
  import type { FetchState } from '@/types/core/fetch_state'

  function useMyHook(): FetchState<User[]> {
    // ...
  }
  ```

code:
  /** Estado de un fetch GET con cache stale-while-revalidate. */
  export interface FetchState<T> {
    /** Datos obtenidos, null si todavia no se han cargado */
    data: T | null
    /** Error si la peticion fallo */
    error: Error | null
    /** Si la peticion esta en progreso */
    loading: boolean
    /** Re-ejecutar la peticion manualmente */
    refetch: () => Promise<T>
    /** Si los datos son stale (cache viejo mostrando mientras refetch en background) */
    is_stale: boolean
  }


id: FormState_ts_core
name: FormState
lang: ts
domain: core
version: 1.0.0
algebraic: product
description: Estado de un formulario con validacion sincrona, tracking de touched y flag de submit en progreso.
tags: ["form","state","validation","touched","hook"]
uses_types: []
file_path: frontend/types/core/form_state.ts
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: 5d844c4147499a4bb93de8470b237dfcfc1270d521c983f9c76aa0506c727a4f
definition:
  interface FormState<T extends Record<string, unknown>> {
    values: T
    errors: Partial<Record<keyof T, string>>
    touched: Partial<Record<keyof T, boolean>>
    is_valid: boolean
    is_submitting: boolean
  }

notes:
  Retornado por `use_form_ts_core`. El generico `T extends Record<string, unknown>` permite tipado completo de los campos y sus errores. Para formularios complejos con validacion async o arrays de campos, usar `@mantine/form` directamente.

documentation:
  ## Campos

  - `values` — valores actuales del formulario, tipados con el generico T
  - `errors` — errores de validacion por campo (string con mensaje), undefined si el campo es valido
  - `touched` — true por campo cuando el usuario lo ha modificado (util para mostrar errores solo al tocar)
  - `is_valid` — true cuando no hay ningun error de validacion en ningun campo
  - `is_submitting` — true mientras el handler de submit esta en ejecucion

  ## Uso

  ```typescript
  import type { FormState } from '@/types/core/form_state'

  interface LoginForm { email: string; password: string }

  function LoginPage() {
    const form: FormState<LoginForm> = useForm({ initial_values: { email: '', password: '' } })
  }
  ```

code:
  /** Estado de un formulario con validacion sincrona. */
  export interface FormState<T extends Record<string, unknown>> {
    /** Valores actuales del formulario */
    values: T
    /** Errores por campo (undefined si el campo no tiene error) */
    errors: Partial<Record<keyof T, string>>
    /** Campos que el usuario ha modificado al menos una vez */
    touched: Partial<Record<keyof T, boolean>>
    /** Si todos los campos pasan la validacion */
    is_valid: boolean
    /** Si el submit esta en progreso */
    is_submitting: boolean
  }


id: MutationState_ts_core
name: MutationState
lang: ts
domain: core
version: 1.0.0
algebraic: product
description: Estado de una mutacion POST/PUT/DELETE. Expone mutate, mutate_async, data, error, loading y reset.
tags: ["mutation","state","http","post","put","delete","hook"]
uses_types: []
file_path: frontend/types/core/mutation_state.ts
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
content_hash: 757f1e3777d4dbc5d9d256de00c124d8d7aca190c8c65281a94454e1f25bafa0
definition:
  interface MutationState<TData, TVariables> {
    mutate: (variables: TVariables) => void
    mutate_async: (variables: TVariables) => Promise<TData>
    data: TData | null
    error: Error | null
    loading: boolean
    reset: () => void
  }

notes:
  Retornado por `use_mutation_ts_core`. El patron `mutate` / `mutate_async` permite usar el hook tanto en event handlers (sin async/await) como en funciones que necesitan esperar el resultado.

documentation:
  ## Campos

  - `mutate` — ejecutar la mutacion de forma fire-and-forget (no lanza excepciones)
  - `mutate_async` — ejecutar la mutacion y obtener una Promise (puede rechazar)
  - `data` — resultado de la ultima mutacion exitosa, null si aun no hubo exito
  - `error` — error de la ultima mutacion fallida, null si fue exitosa o no se ha ejecutado
  - `loading` — true mientras la mutacion esta en vuelo
  - `reset` — volver al estado inicial (idle, data null, error null)

  ## Uso

  ```typescript
  import type { MutationState } from '@/types/core/mutation_state'

  function useCreateUser(): MutationState<User, CreateUserInput> {
    // ...
  }
  ```

code:
  /** Estado de una mutacion POST/PUT/DELETE. */
  export interface MutationState<TData, TVariables> {
    /** Ejecutar la mutacion (fire-and-forget) */
    mutate: (variables: TVariables) => void
    /** Ejecutar la mutacion y esperar el resultado */
    mutate_async: (variables: TVariables) => Promise<TData>
    /** Datos del resultado de la ultima mutacion exitosa */
    data: TData | null
    /** Error si la ultima mutacion fallo */
    error: Error | null
    /** Si hay una mutacion en progreso */
    loading: boolean
    /** Resetear al estado inicial (idle) */
    reset: () => void
  }


id: ChartSeries_ts_ui
name: ChartSeries
lang: ts
domain: ui
version: 1.0.0
algebraic: product
description: Tipos base para series y datos de gráficos. Usados por todos los chart components.
tags: ["chart","series","data","visualization"]
uses_types: []
file_path: frontend/types/ui/chart_series.ts
created_at: 2026-04-03T01:01:56Z
updated_at: 2026-04-03T01:01:56Z
content_hash: 98765369ddb095007d1623a20d61147c3ae4187ad5c57f73035aa804e4a627fc
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/charts/chart-base.tsx
definition:
  interface Series { key: string; name: string; color?: string }
  interface ChartDataPoint { [key: string]: string | number }

notes:
  Tipos mínimos sin dependencias. `ChartDataPoint` usa index signature para permitir cualquier combinación de campos numéricos y categóricos en el mismo punto de datos.

documentation:
  ## Interfaces exportadas

  - `Series` — describe una serie de datos: `key` (campo en el datapoint), `name` (label visible), `color` opcional (override del color del tema)
  - `ChartDataPoint` — mapa genérico `string -> string | number`, representa un punto de datos indexado por clave

code:
  export interface Series {
    key: string
    name: string
    color?: string
  }

  export interface ChartDataPoint {
    [key: string]: string | number
  }


id: ThemeConfig_ts_ui
name: ThemeConfig
lang: ts
domain: ui
version: 1.0.0
algebraic: product
tags: ["theme","design-tokens","colors","typography","ui"]
uses_types: []
file_path: frontend/types/ui/theme_config.ts
created_at: 2026-04-03T01:01:56Z
updated_at: 2026-04-03T01:01:56Z
content_hash: 2d869d04b5b00fc3ef97737a544b8ba9fa6934a8c39337665038a2e636d3e2f4
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/themes/types.ts
definition:
  interface ThemeConfig { name: string; label: string; palette: ColorPalette; colors: SemanticColors; typography: Typography; spacing: Spacing; borders: Borders; shadows: Shadows; motion: Motion; breakpoints: Breakpoints; zIndex: ZIndex; icons: Icons }

description:
  Sistema completo de tokens de diseño: colores semánticos, tipografía, spacing, sombras, motion, breakpoints. Base del theming de Frontend Library.

notes:
  Copia directa de `frontend/src/themes/types.ts` de Frontend_Library. El archivo es autocontenido sin dependencias externas. Los colores usan formato OKLCH via variables CSS.

documentation:
  ## Interfaces exportadas

  - `ColorPalette` — colores primitivos (grays, brand, status, white/black)
  - `SemanticColors` — tokens semánticos (background, foreground, border, brand, status, surface, sidebar, chart)
  - `Typography` — fontFamily, fontSize, fontWeight, lineHeight, letterSpacing
  - `Spacing` — escala 0-96 basada en 4px
  - `Borders` — width y radius
  - `Shadows` — elevación xs-2xl, inner, glow
  - `Motion` — duration e easing
  - `Breakpoints` — xs-2xl
  - `ZIndex` — jerarquía de capas hide-max
  - `Icons` — size y strokeWidth para Phosphor Icons
  - `ThemeConfig` — composición completa de todos los tokens
  - `ThemeColors` — formato legacy para compatibilidad con shadcn/ui
  - `Theme` — nombre + label + ThemeColors
  - `ThemeName` — union type de los temas disponibles

  ## Helper

  `themeConfigToColors(config: ThemeConfig): ThemeColors` — convierte un ThemeConfig al formato legacy ThemeColors que consume shadcn/ui.

code:
  // ============================================================================
  // THEME SYSTEM TYPES
  // Sistema de temas completo para Frontend Library
  // ============================================================================

  // ----------------------------------------------------------------------------
  // 1. COLOR PALETTE (Primitivos)
  // ----------------------------------------------------------------------------

  export interface ColorPalette {
    // Grays (escala neutral)
    gray50: string
    gray100: string
    gray200: string
    gray300: string
    gray400: string
    gray500: string
    gray600: string
    gray700: string
    gray800: string
    gray900: string
    gray950: string

    // Brand colors (personalizables por tema)
    brand50: string
    brand100: string
    brand200: string
    brand300: string
    brand400: string
    brand500: string
    brand600: string
    brand700: string
    brand800: string
    brand900: string
    brand950: string

    // Status colors
    red500: string
    red600: string
    green500: string
    green600: string
    yellow500: string
    yellow600: string
    blue500: string
    blue600: string

    // Pure colors
    white: string
    black: string
    transparent: string
  }

  // ----------------------------------------------------------------------------
  // 2. SEMANTIC COLORS (Tokens semánticos)
  // ----------------------------------------------------------------------------

  export interface SemanticColors {
    // Backgrounds
    background: {
      default: string
      subtle: string
      muted: string
      inverse: string
    }

    // Foregrounds
    foreground: {
      default: string
      muted: string
      subtle: string
      disabled: string
      inverse: string
    }

    // Borders
    border: {
      default: string
      muted: string
      strong: string
      focus: string
    }

    // Brand
    brand: {
      primary: string
      primaryForeground: string
      secondary: string
      secondaryForeground: string
      accent: string
      accentForeground: string
    }

    // Status
    status: {
      success: string
      successForeground: string
      successMuted: string
      warning: string
      warningForeground: string
      warningMuted: string
      error: string
      errorForeground: string
      errorMuted: string
      info: string
      infoForeground: string
      infoMuted: string
    }

    // Surfaces (jerarquía de capas)
    surface: {
      base: string        // Fondo principal
      raised: string      // Cards, panels
      overlay: string     // Modals, dropdowns
      sunken: string      // Inputs, wells
    }

    // Sidebar (panel lateral oscuro)
    sidebar: {
      background: string
      foreground: string
      muted: string
      accent: string
      accentForeground: string
      border: string
      ring: string
    }

    // Charts
    chart: {
      1: string
      2: string
      3: string
      4: string
      5: string
      6: string
      7: string
      8: string
    }

    // Interactive
    ring: string
    input: string
  }

  // ----------------------------------------------------------------------------
  // 3. TYPOGRAPHY
  // ----------------------------------------------------------------------------

  export interface Typography {
    fontFamily: {
      display: string     // Títulos destacados
      heading: string     // Encabezados
      body: string        // Texto general
      mono: string        // Código
    }

    fontSize: {
      xs: string          // 12px
      sm: string          // 14px
      base: string        // 16px
      lg: string          // 18px
      xl: string          // 20px
      '2xl': string       // 24px
      '3xl': string       // 30px
      '4xl': string       // 36px
      '5xl': string       // 48px
      '6xl': string       // 60px
    }

    fontWeight: {
      light: number
      normal: number
      medium: number
      semibold: number
      bold: number
      extrabold: number
    }

    lineHeight: {
      none: number
      tight: number
      snug: number
      normal: number
      relaxed: number
      loose: number
    }

    letterSpacing: {
      tighter: string
      tight: string
      normal: string
      wide: string
      wider: string
    }
  }

  // ----------------------------------------------------------------------------
  // 4. SPACING
  // ----------------------------------------------------------------------------

  export interface Spacing {
    0: string
    px: string
    0.5: string
    1: string
    1.5: string
    2: string
    2.5: string
    3: string
    3.5: string
    4: string
    5: string
    6: string
    7: string
    8: string
    9: string
    10: string
    11: string
    12: string
    14: string
    16: string
    20: string
    24: string
    28: string
    32: string
    36: string
    40: string
    44: string
    48: string
    52: string
    56: string
    60: string
    64: string
    72: string
    80: string
    96: string
  }

  // ----------------------------------------------------------------------------
  // 5. BORDERS
  // ----------------------------------------------------------------------------

  export interface Borders {
    width: {
      none: string
      thin: string
      default: string
      medium: string
      thick: string
    }

    radius: {
      none: string
      sm: string
      default: string
      md: string
      lg: string
      xl: string
      '2xl': string
      '3xl': string
      full: string
    }
  }

  // ----------------------------------------------------------------------------
  // 6. SHADOWS (Elevation)
  // ----------------------------------------------------------------------------

  export interface Shadows {
    none: string
    xs: string
    sm: string
    default: string
    md: string
    lg: string
    xl: string
    '2xl': string
    inner: string
    // Colored shadows for glow effects
    glow: {
      sm: string
      md: string
      lg: string
    }
  }

  // ----------------------------------------------------------------------------
  // 7. MOTION / ANIMATION
  // ----------------------------------------------------------------------------

  export interface Motion {
    duration: {
      instant: string     // 0ms
      fast: string        // 100ms
      normal: string      // 200ms
      slow: string        // 300ms
      slower: string      // 500ms
      slowest: string     // 1000ms
    }

    easing: {
      linear: string
      default: string
      in: string
      out: string
      inOut: string
      spring: string
      bounce: string
    }
  }

  // ----------------------------------------------------------------------------
  // 8. BREAKPOINTS
  // ----------------------------------------------------------------------------

  export interface Breakpoints {
    xs: string
    sm: string
    md: string
    lg: string
    xl: string
    '2xl': string
  }

  // ----------------------------------------------------------------------------
  // 9. Z-INDEX
  // ----------------------------------------------------------------------------

  export interface ZIndex {
    hide: number
    base: number
    raised: number
    dropdown: number
    sticky: number
    overlay: number
    modal: number
    popover: number
    toast: number
    tooltip: number
    max: number
  }

  // ----------------------------------------------------------------------------
  // 10. ICONS
  // ----------------------------------------------------------------------------

  export interface Icons {
    size: {
      xs: string          // 12px
      sm: string          // 16px
      default: string     // 20px
      md: string          // 24px
      lg: string          // 32px
      xl: string          // 40px
      '2xl': string       // 48px
    }

    strokeWidth: {
      thin: number
      light: number
      regular: number
      bold: number
      fill: number
    }
  }

  // ----------------------------------------------------------------------------
  // THEME CONFIGURATION (Complete)
  // ----------------------------------------------------------------------------

  export interface ThemeConfig {
    // Metadata
    name: string
    label: string
    description?: string

    // Color system
    palette: ColorPalette
    colors: SemanticColors

    // Typography
    typography: Typography

    // Layout
    spacing: Spacing
    borders: Borders
    shadows: Shadows

    // Interaction
    motion: Motion

    // Responsive
    breakpoints: Breakpoints
    zIndex: ZIndex

    // Icons
    icons: Icons
  }

  // ----------------------------------------------------------------------------
  // LEGACY SUPPORT - ThemeColors (for backward compatibility)
  // ----------------------------------------------------------------------------

  export interface ThemeColors {
    // Base
    background: string
    foreground: string

    // Cards & Popovers
    card: string
    cardForeground: string
    popover: string
    popoverForeground: string

    // Primary & Secondary
    primary: string
    primaryForeground: string
    secondary: string
    secondaryForeground: string

    // Muted & Accent
    muted: string
    mutedForeground: string
    accent: string
    accentForeground: string

    // Semantic colors
    destructive: string
    destructiveForeground: string
    success: string
    successForeground: string
    warning: string
    warningForeground: string
    info: string
    infoForeground: string

    // Surfaces
    surface: string
    surfaceHover: string
    overlay: string

    // Form elements
    border: string
    input: string
    ring: string

    // Charts
    chart1: string
    chart2: string
    chart3: string
    chart4: string
    chart5: string

    // Sidebar
    sidebar: string
    sidebarForeground: string
    sidebarPrimary: string
    sidebarPrimaryForeground: string
    sidebarAccent: string
    sidebarAccentForeground: string
    sidebarBorder: string
    sidebarRing: string
  }

  export interface Theme {
    name: string
    label: string
    colors: ThemeColors
  }

  export type ThemeName = 'default' | 'dark' | 'blue' | 'green' | 'purple' | 'orange' | 'rose' | 'cyan' | 'sunset' | 'midnight'

  // ----------------------------------------------------------------------------
  // HELPER: Convert ThemeConfig to legacy ThemeColors
  // ----------------------------------------------------------------------------

  export function themeConfigToColors(config: ThemeConfig): ThemeColors {
    const { colors } = config

    return {
      // Base
      background: colors.background.default,
      foreground: colors.foreground.default,

      // Cards & Popovers
      card: colors.surface.raised,
      cardForeground: colors.foreground.default,
      popover: colors.surface.overlay,
      popoverForeground: colors.foreground.default,

      // Primary & Secondary
      primary: colors.brand.primary,
      primaryForeground: colors.brand.primaryForeground,
      secondary: colors.brand.secondary,
      secondaryForeground: colors.brand.secondaryForeground,

      // Muted & Accent
      muted: colors.background.muted,
      mutedForeground: colors.foreground.muted,
      accent: colors.brand.accent,
      accentForeground: colors.brand.accentForeground,

      // Semantic colors
      destructive: colors.status.error,
      destructiveForeground: colors.status.errorForeground,
      success: colors.status.success,
      successForeground: colors.status.successForeground,
      warning: colors.status.warning,
      warningForeground: colors.status.warningForeground,
      info: colors.status.info,
      infoForeground: colors.status.infoForeground,

      // Surfaces
      surface: colors.surface.raised,
      surfaceHover: colors.background.subtle,
      overlay: colors.surface.overlay,

      // Form elements
      border: colors.border.default,
      input: colors.border.default,
      ring: colors.ring,

      // Charts
      chart1: colors.chart[1],
      chart2: colors.chart[2],
      chart3: colors.chart[3],
      chart4: colors.chart[4],
      chart5: colors.chart[5],

      // Sidebar
      sidebar: colors.sidebar.background,
      sidebarForeground: colors.sidebar.foreground,
      sidebarPrimary: colors.brand.primary,
      sidebarPrimaryForeground: colors.brand.primaryForeground,
      sidebarAccent: colors.sidebar.accent,
      sidebarAccentForeground: colors.sidebar.accentForeground,
      sidebarBorder: colors.sidebar.border,
      sidebarRing: colors.sidebar.ring,
    }
  }


id: WailsIPC_ts_ui
name: WailsIPC
lang: ts
domain: ui
version: 1.0.0
algebraic: product
tags: ["wails","ipc","types","query","mutation"]
uses_types: []
file_path: frontend/types/ui/wails_ipc.ts
created_at: 2026-04-03T01:01:56Z
updated_at: 2026-04-03T01:01:56Z
content_hash: ebcba7837d5cdefff5ec2d5afdc39b9a8ec09004c049606aba1e35707e6a0e59
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/lib/wails/types.ts
definition:
  interface QueryState<T> { data: T | null; loading: boolean; error: Error | null; isStale: boolean; lastUpdated: Date | null }
  interface QueryOptions { enabled?: boolean; refetchInterval?: number; refetchOnFocus?: boolean; staleTime?: number; cacheTime?: number; retry?: number | boolean; retryDelay?: number }
  interface MutationOptions { retry?: number | boolean; retryDelay?: number }

description:
  Tipos base para el sistema IPC de Wails: QueryState, QueryOptions, MutationOptions, WailsEvent, defaults.

notes:
  Incluye constantes DEFAULT_QUERY_OPTIONS y DEFAULT_MUTATION_OPTIONS con valores sensibles para IPC (staleTime: 0, cacheTime: 5min, retry: 3).

code:
  /** Estado de una query */
  export interface QueryState<T> {
    data: T | null
    loading: boolean
    error: Error | null
    isStale: boolean
    lastUpdated: Date | null
  }

  /** Opciones comunes para queries */
  export interface QueryOptions {
    /** Ejecutar automáticamente (default: true) */
    enabled?: boolean
    /** Auto-refetch cada N ms */
    refetchInterval?: number
    /** Refetch cuando la ventana gana foco */
    refetchOnFocus?: boolean
    /** Tiempo en ms antes de considerar datos stale (default: 0) */
    staleTime?: number
    /** Tiempo en ms en cache después de unmount (default: 5 min) */
    cacheTime?: number
    /** Número de reintentos en error (default: 3) */
    retry?: number | boolean
    /** Delay entre reintentos en ms */
    retryDelay?: number
  }

  /** Opciones para mutations */
  export interface MutationOptions {
    /** Reintentos en error */
    retry?: number | boolean
    /** Delay entre reintentos */
    retryDelay?: number
  }

  /** Resultado de un evento Wails */
  export interface WailsEvent<T> {
    name: string
    data: T
    timestamp: Date
  }

  /** Estado de una mutation */
  export interface MutationState<T> {
    data: T | null
    loading: boolean
    error: Error | null
  }

  /** Default options */
  export const DEFAULT_QUERY_OPTIONS: Required<QueryOptions> = {
    enabled: true,
    refetchInterval: 0,
    refetchOnFocus: true,
    staleTime: 0,
    cacheTime: 5 * 60 * 1000, // 5 minutos
    retry: 3,
    retryDelay: 1000,
  }

  export const DEFAULT_MUTATION_OPTIONS: Required<MutationOptions> = {
    retry: 0,
    retryDelay: 1000,
  }


id: agent_action_py_core
name: agent_action
lang: py
domain: core
version: 1.0.0
algebraic: product
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
created_at: 2026-04-05T11:19:05Z
updated_at: 2026-04-05T11:19:05Z
content_hash: 87a517c6f7fc953e5e27d6beb26f39360493b34f62ccc3acabaeaa1a927323b7
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

examples:
  ```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())
  ```

notes:
  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.

code:
  """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,
          }


id: base_parser_py_core
name: base_parser
lang: py
domain: core
version: 1.0.0
algebraic: product
tags: ["parser","protocol","abstract","extensible","plugin"]
uses_types: ["parse_result_py_core"]
file_path: python/types/core/base_parser.py
created_at: 2026-04-05T11:24:00Z
updated_at: 2026-04-05T11:24:00Z
content_hash: 7825732c89fc9960edcc5372674aed6de36e8b2e92ca4d01edc78e9733c7ef40
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.

notes:
  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.

code:
  """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


id: code_entity_py_core
name: code_entity
lang: py
domain: core
version: 1.0.0
algebraic: product
tags: ["ast","code","parsing","tree-sitter","symbol","entity","core"]
uses_types: []
file_path: python/types/core/code_entity.py
created_at: 2026-04-05T11:20:30Z
updated_at: 2026-04-05T11:20:30Z
content_hash: da929efef077a638aafc203aed0695f26736456e4eb18664e0c126677d05b621
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.

examples:
  ```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"
  ```

notes:
  - `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).

documentation:
  ## 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"` |

code:
  """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,
          )


id: context_py_core
name: context
lang: py
domain: core
version: 1.0.0
algebraic: product
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
created_at: 2026-04-05T11:21:07Z
updated_at: 2026-04-05T11:21:07Z
content_hash: e499bb055a744fdcaef5aaef8ab54533719e8260b9a5765da1badf097198d537
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.

notes:
  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).

code:
  """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", {}),
          )


id: context_level_py_core
name: context_level
lang: py
domain: core
version: 1.0.0
algebraic: sum
definition:
  class ContextLevel(int, Enum):
  ABSTRACT = 0
  OVERVIEW = 1
  DETAIL = 2

tags: ["context","retrieval","rag","vector-db","hierarchy","level","indexing"]
uses_types: []
file_path: python/types/core/context_level.py
created_at: 2026-04-05T11:20:39Z
updated_at: 2026-04-05T11:20:39Z
content_hash: 6ae453b086e815a280a8fe2653e4fedcfc972ea58c396b7f51275956d2841e41
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.

notes:
  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.

code:
  """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


id: context_part_py_core
name: context_part
lang: py
domain: core
version: 1.0.0
algebraic: product
tags: ["message","part","context","memory","resource","skill","multipart","llm","rag"]
uses_types: []
file_path: python/types/core/context_part.py
created_at: 2026-04-05T11:20:30Z
updated_at: 2026-04-05T11:20:30Z
content_hash: 473562e3f81def5b183bfadd30b97e82e9789609f9b4cbb6feed5dc711d62a44
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.

notes:
  `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"`.

code:
  """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)


id: context_type_py_core
name: context_type
lang: py
domain: core
version: 1.0.0
algebraic: sum
definition:
  class ContextType(str, Enum):
  MEMORY = "memory"
  RESOURCE = "resource"
  SKILL = "skill"

tags: ["retrieval","context","enum","rag","search","filter"]
uses_types: []
file_path: python/types/core/context_type.py
created_at: 2026-04-05T11:20:30Z
updated_at: 2026-04-05T11:20:30Z
content_hash: b914a799febb8475fceb279acffe9794723fffda989b829afe9a69a6b3215766
description:
  Tipo de contexto para busqueda/retrieval. Permite filtrar resultados por categoria: memoria de sesion, recursos indexados, o skills/capacidades.

notes:
  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.

code:
  """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"


id: entity_node_py_core
name: entity_node
lang: py
domain: core
version: 1.0.0
algebraic: product
tags: ["entity","graph","knowledge-graph","node","core"]
uses_types: []
file_path: python/types/core/entity_node.py
created_at: 2026-04-05T11:18:45Z
updated_at: 2026-04-05T11:18:45Z
content_hash: 430d36ea532ddb32c2b64212b725bcd8441243e55bc82bda26448726a1fc5664
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.

examples:
  ```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())
  ```

notes:
  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.

documentation:
  ## 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.

code:
  """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


id: field_type_py_core
name: field_type
lang: py
domain: core
version: 1.0.0
algebraic: sum
tags: ["memory","field","type","enum","schema"]
uses_types: []
file_path: python/types/core/field_type.py
created_at: 2026-04-05T11:20:30Z
updated_at: 2026-04-05T11:20:30Z
content_hash: 2f3f791d3997ac2d39cca02e3b90b0b6d57ea1bec1d3185bf50d054ea53a99e9
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.

notes:
  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.

code:
  """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"


id: filtered_entities_py_core
name: filtered_entities
lang: py
domain: core
version: 1.0.0
algebraic: product
tags: ["entity","graph","knowledge-graph","filter","core"]
uses_types: ["entity_node_py_core"]
file_path: python/types/core/filtered_entities.py
created_at: 2026-04-05T11:18:49Z
updated_at: 2026-04-05T11:18:49Z
content_hash: 9b6c3eb42b337ade2de49b1cdfe8f2774c95aceec260e90340e62c6730998c89
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.

examples:
  ```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())
  ```

notes:
  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.

documentation:
  ## Metodos

  - `to_dict() -> dict` — serializa el resultado a un diccionario, incluyendo cada entidad serializada con `EntityNode.to_dict()`.

code:
  """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,
          }


id: find_result_py_core
name: find_result
lang: py
domain: core
version: 1.0.0
algebraic: product
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
created_at: 2026-04-05T11:22:16Z
updated_at: 2026-04-05T11:22:16Z
content_hash: 691a83e7857f459e9a3a16bcee5feee345c8a128115e6fea08b1e6d4b9e0bc76
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.

notes:
  `__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.

code:
  """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", [])],
          )


id: matched_context_py_core
name: matched_context
lang: py
domain: core
version: 1.0.0
algebraic: product
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
created_at: 2026-04-05T11:21:19Z
updated_at: 2026-04-05T11:21:19Z
content_hash: d096356769455328d9e6517c6bc2f21731aeca78c0f887dfd8d872a8070c7a77
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.

notes:
  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.

code:
  """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)


id: memory_data_py_core
name: memory_data
lang: py
domain: core
version: 1.0.0
algebraic: product
tags: ["memory","data","agent","dataclass","retrieval","hierarchical"]
uses_types: ["memory_type_schema_py_core"]
file_path: python/types/core/memory_data.py
created_at: 2026-04-05T11:21:23Z
updated_at: 2026-04-05T11:21:23Z
content_hash: 02e6453d71d1ab54a3cf70b7add62709647ffa577428fc4e081f572af8b760d6
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.

examples:
  ```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"
  ```

notes:
  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`.

code:
  """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


id: memory_field_py_core
name: memory_field
lang: py
domain: core
version: 1.0.0
algebraic: product
tags: ["memory","field","schema","dataclass"]
uses_types: ["field_type_py_core","merge_op_py_core"]
file_path: python/types/core/memory_field.py
created_at: 2026-04-05T11:20:46Z
updated_at: 2026-04-05T11:20:46Z
content_hash: bdec74e005ca3871faf7cfa472044f602c5f8af11ae004de7f4afe77016ea528
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.

examples:
  ```python
  MemoryField(
      name="username",
      field_type=FieldType.STRING,
      description="Nombre de usuario del sistema",
      merge_op=MergeOp.IMMUTABLE,
  )
  ```

notes:
  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`.

code:
  """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


