# functions

id: simple_select_ts_ui
name: simple_select
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: SimpleSelect(props: SimpleSelectProps): JSX.Element
tags: ["select","dropdown","form","component","ui","simple"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/simple_select.tsx
created_at: 2026-04-06T00:12:20Z
updated_at: 2026-04-06T00:12:20Z
emits: []
framework: react
variant: ["default","sm"]
content_hash: 9599c3ff2261bdde59990b287fe2191849b325a8e73c5cd799ec69346f7b74db
description:
  Select simplificado que acepta un array plano o agrupado de opciones. Wrapper sobre Select con API declarativa.

example:
  ```tsx
  import { SimpleSelect } from '@fn_library'

  // Opciones planas
  const options = [
    { value: 'a', label: 'Opcion A' },
    { value: 'b', label: 'Opcion B' },
  ]

  <SimpleSelect value={selected} onValueChange={setSelected} options={options} />

  // Opciones agrupadas
  const grouped = [
    { group: 'Frutas', items: [{ value: 'apple', label: 'Manzana' }] },
    { group: 'Verduras', items: [{ value: 'carrot', label: 'Zanahoria' }] },
  ]

  <SimpleSelect value={selected} onValueChange={setSelected} options={grouped} />
  ```

props:
  [{"name":"value","type":"string","required":true,"description":"Valor seleccionado actualmente"},{"name":"onValueChange","type":"(value: string) =\u003e void","required":true,"description":"Callback cuando cambia la seleccion"},{"name":"options","type":"SimpleSelectOption[] | SimpleSelectGroup[]","required":true,"description":"Opciones planas o agrupadas"},{"name":"placeholder","type":"string","required":false,"description":"Texto cuando no hay seleccion"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el select"},{"name":"size","type":"'sm' | 'default'","required":false,"description":"Tamano del trigger"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  - Detecta automaticamente si las opciones son planas o agrupadas via type guard `isGrouped`.
  - Wrapper sobre `Select` del registry — toda la logica de Base-UI y accesibilidad viene del componente base.
  - Soporta items deshabilitados individualmente con `disabled: true` en cada opcion.

code:
  import { NativeSelect } from '@mantine/core'

  export interface SimpleSelectOption {
    value: string
    label: string
    disabled?: boolean
  }

  export interface SimpleSelectGroup {
    group: string
    items: SimpleSelectOption[]
  }

  export type SimpleSelectOptions = SimpleSelectOption[] | SimpleSelectGroup[]

  interface SimpleSelectProps {
    value: string
    onValueChange: (value: string) => void
    options: SimpleSelectOptions
    placeholder?: string
    disabled?: boolean
    size?: 'sm' | 'default'
    className?: string
  }

  function isGrouped(
    options: SimpleSelectOptions,
  ): options is SimpleSelectGroup[] {
    return options.length > 0 && options[0] != null && 'group' in options[0]
  }

  function toMantineData(options: SimpleSelectOptions, placeholder?: string) {
    const data: Array<{ value: string; label: string; disabled?: boolean } | { group: string; items: Array<{ value: string; label: string; disabled?: boolean }> }> = []

    if (placeholder) {
      data.push({ value: '', label: placeholder, disabled: true })
    }

    if (isGrouped(options)) {
      for (const g of options) {
        data.push({ group: g.group, items: g.items })
      }
    } else {
      for (const item of options) {
        data.push(item)
      }
    }

    return data
  }

  function SimpleSelect({
    value,
    onValueChange,
    options,
    placeholder = 'Select...',
    disabled = false,
    size = 'default',
    className,
  }: SimpleSelectProps) {
    return (
      <NativeSelect
        value={value}
        onChange={(e) => onValueChange(e.currentTarget.value)}
        data={toMantineData(options, !value ? placeholder : undefined)}
        disabled={disabled}
        size={size === 'sm' ? 'xs' : 'sm'}
        className={className}
        styles={{ input: { cursor: 'pointer' } }}
      />
    )
  }

  export { SimpleSelect }

params_schema:
  {"output":"Componente SimpleSelect que renderiza dropdown simplificado con soporte para opciones planas o agrupadas"}


id: skeleton_ts_ui
name: skeleton
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Skeleton(props: HTMLAttributes<HTMLDivElement>): JSX.Element
tags: ["skeleton","loading","placeholder","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/skeleton.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: []
framework: react
variant: ["base","text","card","avatar","button","table"]
content_hash: 398c66bd4f82c016175fa5ddf2c8c8ca172c66c50bb160bb312cfd8f9ccb3874
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/skeleton.tsx
description:
  Sistema de loading skeletons: base, text, card, avatar, button, table. Variantes preconfiguradas para estados de carga. Mantine Skeleton.

example:
  ```tsx
  <SkeletonCard />
  <SkeletonText lines={4} />
  <SkeletonTable rows={10} columns={5} />
  <SkeletonAvatar size="lg" />
  <SkeletonButton />
  ```

props:
  [{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"},{"name":"lines","type":"number","required":false,"description":"Número de líneas (SkeletonText, default 3)"},{"name":"rows","type":"number","required":false,"description":"Filas de tabla (SkeletonTable, default 5)"},{"name":"columns","type":"number","required":false,"description":"Columnas de tabla (SkeletonTable, default 4)"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño del avatar (SkeletonAvatar, default md)"}]

notes:
  Exporta 6 variantes preconfiguradas. Todas componen sobre Mantine Skeleton con animacion nativa.
  La ultima linea de SkeletonText se acorta a 80% para simular texto real.
  SkeletonCard incluye imagen (180px) + dos lineas de texto. SkeletonAvatar usa circle para forma redonda.

code:
  import * as React from "react"
  import { Skeleton as MantineSkeleton, Group, Stack } from "@mantine/core"

  function Skeleton({ className, style, ...props }: React.HTMLAttributes<HTMLDivElement>) {
    return <MantineSkeleton data-slot="skeleton" className={className} style={style} {...props} />
  }

  function SkeletonText({ className, lines = 3, ...props }: React.HTMLAttributes<HTMLDivElement> & { lines?: number }) {
    return (
      <Stack gap="xs" className={className} data-slot="skeleton-text" {...props}>
        {Array.from({ length: lines }).map((_, i) => (
          <MantineSkeleton key={i} h={16} w={i === lines - 1 ? "80%" : "100%"} />
        ))}
      </Stack>
    )
  }

  function SkeletonCard({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
    return (
      <Stack gap="xs" className={className} data-slot="skeleton-card" {...props}>
        <MantineSkeleton h={180} w="100%" radius="md" />
        <MantineSkeleton h={16} w="75%" />
        <MantineSkeleton h={16} w="50%" />
      </Stack>
    )
  }

  const avatarSizeMap = { xs: 24, sm: 32, md: 40, lg: 48, xl: 64 }

  function SkeletonAvatar({ className, size = "md", ...props }: React.HTMLAttributes<HTMLDivElement> & { size?: "xs" | "sm" | "md" | "lg" | "xl" }) {
    const px = avatarSizeMap[size]
    return <MantineSkeleton data-slot="skeleton-avatar" circle h={px} w={px} className={className} {...props} />
  }

  function SkeletonButton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
    return <MantineSkeleton data-slot="skeleton-button" h={32} w={96} radius="md" className={className} {...props} />
  }

  function SkeletonTable({ className, rows = 5, columns = 4, ...props }: React.HTMLAttributes<HTMLDivElement> & { rows?: number; columns?: number }) {
    return (
      <Stack gap="xs" className={className} data-slot="skeleton-table" {...props}>
        <Group gap="sm">
          {Array.from({ length: columns }).map((_, i) => <MantineSkeleton key={i} h={16} style={{ flex: 1 }} />)}
        </Group>
        {Array.from({ length: rows }).map((_, rowIndex) => (
          <Group key={rowIndex} gap="sm">
            {Array.from({ length: columns }).map((_, colIndex) => <MantineSkeleton key={colIndex} h={16} style={{ flex: 1 }} />)}
          </Group>
        ))}
      </Stack>
    )
  }

  export { Skeleton, SkeletonAvatar, SkeletonButton, SkeletonCard, SkeletonTable, SkeletonText }

params_schema:
  {"output":"Componente Skeleton que renderiza placeholder de carga con 6 variantes preconfiguradas (text, card, avatar, button, table)"}


id: slider_ts_ui
name: slider
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Slider(props: SliderProps): JSX.Element | RangeSlider(props: RangeSliderProps): JSX.Element
tags: ["slider","range","input","numeric","component","ui","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/slider.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:42:46Z
emits: ["onChange","onChangeEnd"]
framework: react
variant: ["default","range"]
content_hash: 9f30bcbfeb760650cd8f336dd02488671bf4f8a5953e6ac223b8c60aa7340297
description:
  Deslizador de valor numérico con marcas, labels y modo rango. Incluye RangeSlider. Wrapper sobre Mantine Slider.

example:
  ```tsx
  import { Slider, RangeSlider } from '@fn_library'

  // Slider básico
  <Slider
    value={volume}
    onChange={setVolume}
    min={0}
    max={100}
    step={5}
  />

  // Con marcas y label formateado
  <Slider
    value={price}
    onChange={setPrice}
    min={0}
    max={1000}
    step={50}
    label={(v) => `$${v}`}
    labelAlwaysOn
    marks={[
      { value: 0, label: '$0' },
      { value: 500, label: '$500' },
      { value: 1000, label: '$1000' },
    ]}
  />

  // RangeSlider para seleccionar intervalo
  <RangeSlider
    value={priceRange}
    onChange={setPriceRange}
    min={0}
    max={500}
    onChangeEnd={(range) => fetchProducts(range)}
  />
  ```

props:
  [{"name":"min","type":"number","required":false,"description":"Valor mínimo del rango (por defecto 0)"},{"name":"max","type":"number","required":false,"description":"Valor máximo del rango (por defecto 100)"},{"name":"step","type":"number","required":false,"description":"Incremento por paso al deslizar"},{"name":"marks","type":"{ value: number; label?: string }[]","required":false,"description":"Marcas en el track con etiquetas opcionales"},{"name":"value","type":"number","required":false,"description":"Valor actual del slider (controlled) — para RangeSlider es [number, number]"},{"name":"onChange","type":"(value: number) =\u003e void","required":false,"description":"Callback mientras se arrastra — para RangeSlider recibe [number, number]"},{"name":"onChangeEnd","type":"(value: number) =\u003e void","required":false,"description":"Callback al soltar el slider — para RangeSlider recibe [number, number]"},{"name":"label","type":"string | ((value: number) =\u003e string) | null","required":false,"description":"Tooltip sobre el thumb — función para formatear el valor"},{"name":"labelAlwaysOn","type":"boolean","required":false,"description":"Muestra el label siempre visible, no solo al hover"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita la interacción"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Grosor del track y tamaño del thumb"},{"name":"color","type":"string","required":false,"description":"Color del track activo y el thumb"}]

notes:
  - Wrapper directo sobre `Slider` y `RangeSlider` de `@mantine/core` v9. Todas las props de Mantine son válidas.
  - `RangeSlider` recibe y emite `[number, number]` en lugar de `number`.
  - Usar `onChangeEnd` para operaciones costosas (fetch, cálculos) y `onChange` solo para actualizar UI.
  - `label={null}` desactiva el tooltip completamente.

code:
  import { Slider as MantineSlider, RangeSlider as MantineRangeSlider } from '@mantine/core'
  import type { SliderProps as MantineSliderProps, RangeSliderProps as MantineRangeSliderProps } from '@mantine/core'

  interface SliderProps extends MantineSliderProps {}
  interface RangeSliderProps extends MantineRangeSliderProps {}

  function Slider(props: SliderProps) {
    return <MantineSlider {...props} />
  }

  function RangeSlider(props: RangeSliderProps) {
    return <MantineRangeSlider {...props} />
  }

  export { Slider, RangeSlider }
  export type { SliderProps, RangeSliderProps }

params_schema:
  {"output":"Componente Slider para selección de un valor numérico en un rango, o RangeSlider para selección de un intervalo [min, max]"}


id: sparkline_ts_ui
name: sparkline
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Sparkline(props: SparklineProps): JSX.Element
tags: ["sparkline","chart","inline","svg","component","ui","visualization"]
uses_functions: []
uses_types: []
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/sparkline.tsx
created_at: 2026-04-06T00:12:06Z
updated_at: 2026-04-06T00:12:06Z
emits: []
framework: react
variant: ["line","area","bar"]
notes: SVG puro — sin dependencia de Recharts. Ideal para inline en tablas y cards.
content_hash: 918d4c618c1e02c554b7e50d98195c573c55d67bc1b08ca76f9cf9993f8cbf54
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/sparkline.tsx
description:
  Mini gráfico inline SVG puro (sin Recharts) con variantes line, area y bar. Para KPI cards y tablas.

example:
  ```tsx
  <Sparkline data={[10, 25, 15, 40, 30, 55]} variant="area" color="#22c55e" />
  <Sparkline data={[5, 3, 8, 1, 9]} variant="bar" width={60} height={20} />
  ```

props:
  [{"name":"data","type":"number[]","required":true,"description":"Array de valores numéricos"},{"name":"variant","type":"'line' | 'area' | 'bar'","required":false,"description":"Tipo de visualización"},{"name":"color","type":"string","required":false,"description":"Color del gráfico"},{"name":"colors","type":"string[]","required":false,"description":"Colores por barra para variant 'bar'. Cicla si es más corto que data."},{"name":"width","type":"number","required":false,"description":"Ancho en px (default 80)"},{"name":"height","type":"number","required":false,"description":"Alto en px (default 24)"}]

code:
  import * as React from 'react'
  import { Sparkline as MantineSparkline } from '@mantine/charts'

  type SparklineVariant = 'line' | 'area' | 'bar'

  interface SparklineProps {
    data: number[]
    variant?: SparklineVariant
    color?: string
    colors?: string[]
    width?: number
    height?: number
    strokeWidth?: number
    showLastPoint?: boolean
    className?: string
  }

  const Sparkline = React.forwardRef<HTMLDivElement, SparklineProps>(
    ({ data, variant = 'line', color, colors, width = 80, height = 24, strokeWidth = 1.5, className, ...props }, ref) => {
      if (data.length === 0) {
        return <div ref={ref} style={{ width, height }} className={className} />
      }

      // Bar variant: use custom SVG (Mantine Sparkline only supports area)
      if (variant === 'bar') {
        const min = Math.min(...data, 0)
        const max = Math.max(...data)
        const range = max - min || 1
        const p = 2
        const eh = height - p * 2
        const bw = (width - p * 2) / data.length - 1
        const barColor = color ?? 'currentColor'
        return (
          <div ref={ref} className={className} {...props}>
            <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
              {data.map((value, index) => {
                const bh = ((value - min) / range) * eh
                const x = p + index * ((width - p * 2) / data.length) + 0.5
                const y = p + eh - bh
                const fill = colors ? colors[index % colors.length] : barColor
                return <rect key={index} x={x} y={y} width={Math.max(bw, 1)} height={Math.max(bh, 1)} fill={fill} rx={1} opacity={0.85} />
              })}
            </svg>
          </div>
        )
      }

      // Line/area: use Mantine Sparkline
      return (
        <div ref={ref} className={className} {...props}>
          <MantineSparkline
            data={data}
            color={color ?? 'blue'}
            w={width}
            h={height}
            strokeWidth={strokeWidth}
            curveType="natural"
            withGradient={variant === 'area'}
            fillOpacity={variant === 'area' ? 0.3 : 0}
          />
        </div>
      )
    }
  )
  Sparkline.displayName = 'Sparkline'

  export { Sparkline, type SparklineProps, type SparklineVariant }

params_schema:
  {"output":"Componente Sparkline que renderiza mini gráfico SVG inline con variantes line, area y bar"}


id: stepper_ts_ui
name: stepper
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnStepper(props: FnStepperProps): JSX.Element
description: Stepper de pasos con orientacion horizontal/vertical. Wrapper sobre Mantine Stepper.
tags: ["mantine","stepper","steps","wizard","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/stepper.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: []
content_hash: 6dfcdae814a7f1b5039fbfbc311e724139034e19bca3bad4cb39877a16a3e0f5
params_schema: {"output":"Stepper con indicadores de paso, labels y descripciones opcionales"}
example:
  ```tsx
  import { FnStepper } from '@fn_library'

  <FnStepper
    active={1}
    onStepClick={setActive}
    steps={[
      { label: 'Configuracion' },
      { label: 'Datos', description: 'Importar CSV' },
      { label: 'Revision' },
    ]}
  />
  ```

props:
  [{"name":"steps","type":"{ label: string; description?: string; icon?: ReactNode }[]","required":true,"description":"Definicion de cada paso del stepper"},{"name":"active","type":"number","required":true,"description":"Indice del paso activo (0-based)"},{"name":"onStepClick","type":"(step: number) =\u003e void","required":false,"description":"Callback al hacer click en un paso"},{"name":"orientation","type":"'horizontal' | 'vertical'","required":false,"description":"Orientacion del stepper, default horizontal"},{"name":"allowNextStepsSelect","type":"boolean","required":false,"description":"Permite seleccionar pasos futuros, default false"}]

notes:
  Wrapper sobre Mantine `Stepper`. Recibe un array declarativo de pasos en vez de children manuales. Soporta iconos custom por paso.

code:
  import * as React from 'react'
  import { Stepper } from '@mantine/core'

  interface StepDef {
    label: string
    description?: string
    icon?: React.ReactNode
  }

  interface FnStepperProps {
    steps: StepDef[]
    active: number
    onStepClick?: (step: number) => void
    orientation?: 'horizontal' | 'vertical'
    allowNextStepsSelect?: boolean
  }

  function FnStepper({
    steps,
    active,
    onStepClick,
    orientation = 'horizontal',
    allowNextStepsSelect = false,
  }: FnStepperProps) {
    return (
      <Stepper
        active={active}
        onStepClick={onStepClick}
        orientation={orientation}
        allowNextStepsSelect={allowNextStepsSelect}
      >
        {steps.map((step, i) => (
          <Stepper.Step
            key={i}
            label={step.label}
            description={step.description}
            icon={step.icon}
          />
        ))}
      </Stepper>
    )
  }

  export { FnStepper }
  export type { FnStepperProps, StepDef }


id: switch_toggle_ts_ui
name: switch_toggle
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: SwitchToggle(props: SwitchToggleProps): JSX.Element
description: Toggle on/off accesible con label opcional a izquierda o derecha. Mantine Switch.
tags: ["switch","toggle","component","ui","interactive","form","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/switch_toggle.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onCheckedChange"]
framework: react
variant: []
content_hash: 404ac6c3d356c36b3d0f3e480f83c0f84254e102cef2b7375d1c3648e8b8f284
params_schema: {"output":"Componente SwitchToggle que renderiza switch on/off accesible con label opcional"}
example:
  ```tsx
  // Label a la derecha (default)
  <SwitchToggle label="Notificaciones" defaultChecked />

  // Label a la izquierda
  <SwitchToggle label="Modo oscuro" labelPosition="left" checked={dark} onCheckedChange={setDark} />

  // Solo switch sin label
  <SwitchToggle checked={enabled} onCheckedChange={setEnabled} />
  ```

props:
  [{"name":"label","type":"string","required":false,"description":"Texto de etiqueta visible junto al switch"},{"name":"labelPosition","type":"'left' | 'right'","required":false,"description":"Posicion del label respecto al switch (default: right)"},{"name":"checked","type":"boolean","required":false,"description":"Estado controlado del toggle"},{"name":"defaultChecked","type":"boolean","required":false,"description":"Estado inicial no controlado"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el toggle"},{"name":"onCheckedChange","type":"(checked: boolean) =\u003e void","required":false,"description":"Callback cuando cambia el estado"}]

notes:
  Usa Mantine Switch. Soporta labelPosition nativo de Mantine para posicionar el label a izquierda o derecha. El callback onCheckedChange se adapta desde el onChange nativo.

code:
  import { Switch } from "@mantine/core"

  interface SwitchToggleProps {
    label?: string
    labelPosition?: "left" | "right"
    className?: string
    checked?: boolean
    defaultChecked?: boolean
    disabled?: boolean
    onCheckedChange?: (checked: boolean) => void
    id?: string
  }

  function SwitchToggle({ className, label, labelPosition = "right", id, checked, defaultChecked, disabled, onCheckedChange, ...props }: SwitchToggleProps) {
    return (
      <Switch
        id={id}
        data-slot="switch"
        label={label}
        labelPosition={labelPosition}
        checked={checked}
        defaultChecked={defaultChecked}
        disabled={disabled}
        onChange={(event) => onCheckedChange?.(event.currentTarget.checked)}
        className={className}
        size="sm"
        {...props}
      />
    )
  }

  export { SwitchToggle }
  export type { SwitchToggleProps }


id: tabs_ts_ui
name: tabs
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Tabs(props: TabsRootProps): JSX.Element
description: Sistema de tabs con orientacion horizontal/vertical, variantes default y line. Mantine Tabs.
tags: ["tabs","navigation","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/tabs.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onValueChange"]
framework: react
variant: ["default","line"]
content_hash: 45cc0d17c6cb092f9fc4e479e85b16765e3fe490056f00235338871108533594
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/tabs.tsx
example:
  ```tsx
  <Tabs defaultValue="general">
    <TabsList>
      <TabsTrigger value="general">General</TabsTrigger>
      <TabsTrigger value="security">Security</TabsTrigger>
    </TabsList>
    <TabsContent value="general">...</TabsContent>
    <TabsContent value="security">...</TabsContent>
  </Tabs>
  ```

props:
  [{"name":"orientation","type":"'horizontal' | 'vertical'","required":false,"description":"Orientación de los tabs"},{"name":"variant","type":"'default' | 'line'","required":false,"description":"Estilo visual de la lista"}]

code:
  import * as React from "react"
  import { Tabs as MantineTabs } from "@mantine/core"

  interface TabsProps {
    defaultValue?: string | null
    value?: string | null
    onTabChange?: (value: string | null) => void
    orientation?: "horizontal" | "vertical"
    variant?: "default" | "line"
    className?: string
    children?: React.ReactNode
  }

  function Tabs({ className, orientation = "horizontal", variant = "default", defaultValue, value, onTabChange, children, ...props }: TabsProps) {
    return (
      <MantineTabs
        data-slot="tabs"
        defaultValue={defaultValue}
        value={value}
        onChange={onTabChange}
        orientation={orientation}
        variant={variant === "line" ? "outline" : "default"}
        className={className}
        {...props}
      >
        {children}
      </MantineTabs>
    )
  }

  function TabsList({ className, variant, children, ...props }: { className?: string; variant?: "default" | "line"; children?: React.ReactNode } & React.ComponentProps<typeof MantineTabs.List>) {
    return (
      <MantineTabs.List data-slot="tabs-list" className={className} {...props}>
        {children}
      </MantineTabs.List>
    )
  }

  function TabsTrigger({ className, value, children, disabled, ...props }: { className?: string; value: string; children?: React.ReactNode; disabled?: boolean }) {
    return (
      <MantineTabs.Tab data-slot="tabs-trigger" value={value} disabled={disabled} className={className} {...props}>
        {children}
      </MantineTabs.Tab>
    )
  }

  function TabsContent({ className, value, children, ...props }: { className?: string; value: string; children?: React.ReactNode }) {
    return (
      <MantineTabs.Panel data-slot="tabs-content" value={value} className={className} {...props}>
        {children}
      </MantineTabs.Panel>
    )
  }

  export { Tabs, TabsList, TabsTrigger, TabsContent }

params_schema:
  {"output":"Componente Tabs que renderiza sistema de navegación por tabs con orientación y variantes configurables"}


id: tags_input_ts_ui
name: tags_input
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: TagsInput(props: TagsInputProps): JSX.Element
tags: ["tags","input","form","pills","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/tags_input.tsx
created_at: 2026-04-06T10:42:46Z
updated_at: 2026-04-06T10:42:46Z
emits: ["onChange"]
framework: react
variant: []
content_hash: cd3ed5231ca84a999df7d62763d992f90132e198830e0a753e2c3d03a6f554b8
params_schema: {"output":"Componente TagsInput que renderiza input de tags con creación libre y sugerencias"}
description:
  Input de tags libre con sugerencias opcionales. Permite crear valores custom a diferencia de MultiSelect. Wrapper sobre Mantine TagsInput.

example:
  ```tsx
  import { TagsInput } from '@fn_library'

  // Tags libres sin sugerencias
  <TagsInput
    label="Tecnologías"
    placeholder="Añade una tecnología y presiona Enter"
    onChange={(tags) => console.log(tags)}
  />

  // Con sugerencias y límite de tags
  <TagsInput
    label="Categorías"
    placeholder="Selecciona o escribe"
    data={['Frontend', 'Backend', 'DevOps', 'QA', 'Diseño']}
    maxTags={3}
    clearable
    onChange={(tags) => setSelectedTags(tags)}
  />
  ```

props:
  [{"name":"data","type":"string[]","required":false,"description":"Lista de sugerencias para autocompletar"},{"name":"value","type":"string[]","required":false,"description":"Valor controlado: array de tags activos"},{"name":"onChange","type":"(value: string[]) =\u003e void","required":false,"description":"Callback cuando cambia la lista de tags"},{"name":"maxTags","type":"number","required":false,"description":"Número máximo de tags permitidos"},{"name":"allowDuplicates","type":"boolean","required":false,"description":"Permite tags duplicados. Por defecto false"},{"name":"splitChars","type":"string[]","required":false,"description":"Caracteres que dividen la entrada en tags. Por defecto [',']"},{"name":"clearable","type":"boolean","required":false,"description":"Muestra botón para limpiar todos los tags"},{"name":"placeholder","type":"string","required":false,"description":"Texto placeholder cuando no hay tags"},{"name":"label","type":"string","required":false,"description":"Etiqueta visible sobre el input"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el input"},{"name":"size","type":"'xs' | 'sm' | 'md' | 'lg' | 'xl'","required":false,"description":"Tamaño del componente"}]

notes:
  A diferencia de MultiSelect, TagsInput permite crear valores que no están en la lista `data`. El usuario escribe libremente y confirma con Enter o con los caracteres definidos en `splitChars`. Útil para campos de etiquetas, habilidades o keywords donde el conjunto no es cerrado.

code:
  import { TagsInput as MantineTagsInput, type TagsInputProps as MantineTagsInputProps } from '@mantine/core'

  interface TagsInputProps extends MantineTagsInputProps {}

  function TagsInput(props: TagsInputProps) {
    return <MantineTagsInput {...props} />
  }

  export { TagsInput }
  export type { TagsInputProps }


id: textarea_ts_ui
name: textarea
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Textarea(props: TextareaProps): JSX.Element
description: Input multilinea accesible con auto-resize opcional. Mantine Textarea con autosize.
tags: ["textarea","component","ui","interactive","form","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/textarea.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onChange","onFocus","onBlur"]
framework: react
variant: []
content_hash: 41f1d9b15cf6c8123d42e3a0d96e5ae856b9ce2fbeeb3d0824e51a113f47d4ff
params_schema: {"output":"Componente Textarea que renderiza input multilinea accesible con auto-resize opcional"}
example:
  ```tsx
  // Basico
  <Textarea placeholder="Escribe aqui..." rows={4} />

  // Con auto-resize
  <Textarea autoResize placeholder="Crece automaticamente..." />

  // Controlado
  <Textarea value={text} onChange={(e) => setText(e.target.value)} />

  // Con validacion
  <Textarea aria-invalid={!!error} />
  ```

props:
  [{"name":"autoResize","type":"boolean","required":false,"description":"Ajusta la altura automaticamente al contenido (default: false)"},{"name":"placeholder","type":"string","required":false,"description":"Texto placeholder"},{"name":"disabled","type":"boolean","required":false,"description":"Deshabilita el textarea"},{"name":"rows","type":"number","required":false,"description":"Numero de filas visibles iniciales"},{"name":"className","type":"string","required":false,"description":"Clases CSS adicionales"}]

notes:
  Usa Mantine Textarea con autosize nativo (reemplaza el auto-resize manual anterior). forwardRef mantenido para compatibilidad con form libraries.

code:
  import * as React from 'react'
  import { Textarea as MantineTextarea } from '@mantine/core'

  interface TextareaProps extends Omit<React.ComponentProps<typeof MantineTextarea>, 'autosize'> {
    autoResize?: boolean
  }

  const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
    ({ className, autoResize = false, ...props }, ref) => {
      return (
        <MantineTextarea
          ref={ref}
          data-slot="textarea"
          autosize={autoResize}
          minRows={autoResize ? 2 : undefined}
          size="sm"
          className={className}
          {...props}
        />
      )
    }
  )
  Textarea.displayName = 'Textarea'

  export { Textarea }
  export type { TextareaProps }


id: timeline_ts_ui
name: timeline
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: FnTimeline(props: FnTimelineProps): JSX.Element
description: Timeline vertical con items, iconos y colores. Wrapper sobre Mantine Timeline.
tags: ["mantine","timeline","history","events","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core"]
tests: []
file_path: frontend/functions/ui/timeline.tsx
created_at: 2026-04-06T00:11:54Z
updated_at: 2026-04-06T00:11:54Z
emits: []
framework: react
variant: []
content_hash: f2cd3540feb6ef76cba54f62e0b0bcde882eac4f2f28f66b5d15506b7ac09f07
params_schema: {"output":"Timeline vertical con bullets, titulos, timestamps y descripciones"}
example:
  ```tsx
  import { FnTimeline } from '@fn_library'

  <FnTimeline
    active={1}
    items={[
      { title: 'Creado', time: '2024-01-01', icon: <IconCheck size={12} /> },
      { title: 'En progreso', description: 'Pipeline ejecutando', color: 'blue' },
      { title: 'Completado' },
    ]}
  />
  ```

props:
  [{"name":"items","type":"{ title: string; description?: string; icon?: ReactNode; color?: string; time?: string }[]","required":true,"description":"Items de la timeline con titulo, descripcion, icono, color y timestamp"},{"name":"active","type":"number","required":false,"description":"Indice del item activo, default -1 (ninguno)"},{"name":"bulletSize","type":"number","required":false,"description":"Tamano del bullet en px, default 20"}]

notes:
  Wrapper sobre Mantine `Timeline`. Recibe array declarativo de items. El `time` se muestra como texto dimmed debajo del titulo. Items despues de `active` se muestran como inactivos.

code:
  import * as React from 'react'
  import { Timeline, Text } from '@mantine/core'

  interface TimelineItem {
    title: string
    description?: string
    icon?: React.ReactNode
    color?: string
    time?: string
  }

  interface FnTimelineProps {
    items: TimelineItem[]
    active?: number
    bulletSize?: number
  }

  function FnTimeline({
    items,
    active = -1,
    bulletSize = 20,
  }: FnTimelineProps) {
    return (
      <Timeline active={active} bulletSize={bulletSize}>
        {items.map((item, i) => (
          <Timeline.Item
            key={i}
            title={item.title}
            bullet={item.icon}
            color={item.color}
          >
            {item.time && (
              <Text size="xs" c="dimmed" mt={2}>{item.time}</Text>
            )}
            {item.description && (
              <Text size="sm" mt={4}>{item.description}</Text>
            )}
          </Timeline.Item>
        ))}
      </Timeline>
    )
  }

  export { FnTimeline }
  export type { FnTimelineProps, TimelineItem }


id: toast_ts_ui
name: toast
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Toast(props: ToastProps): JSX.Element
tags: ["toast","notification","alert","component","ui","interactive","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/notifications","@mantine/core"]
tests: []
file_path: frontend/functions/ui/toast.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: ["onClose"]
framework: react
variant: ["default","success","error","warning","info"]
content_hash: a4bad4a2b61da0ec3db1060012d6f26c2bfe8d41ff16984910fb80744dc47d12
description:
  Notificaciones temporales con variantes semanticas (success, error, warning, info), iconos automaticos, auto-dismiss y provider con hook useToast.

example:
  ```tsx
  // 1. Envolver la app con el provider
  <ToastProvider position="bottom-right">
    <App />
  </ToastProvider>

  // 2. Usar el hook en cualquier componente
  function MyComponent() {
    const { toast } = useToast()

    return (
      <Button onClick={() => toast({
        variant: "success",
        title: "Guardado",
        description: "Los cambios se guardaron correctamente.",
      })}>
        Guardar
      </Button>
    )
  }

  // Toast con accion
  toast({
    variant: "error",
    title: "Error al guardar",
    description: "Intenta de nuevo.",
    action: <Button size="sm" variant="outline">Reintentar</Button>,
    duration: 0, // persistente hasta cerrar manualmente
  })

  // Toast individual sin provider
  <Toast variant="info" title="Informacion" description="Texto descriptivo" onClose={() => {}} />
  ```

props:
  [{"name":"variant","type":"'default' | 'success' | 'error' | 'warning' | 'info'","required":false,"description":"Variante semantica con icono automatico (default: default)"},{"name":"title","type":"string","required":false,"description":"Titulo de la notificacion"},{"name":"description","type":"string","required":false,"description":"Texto descriptivo secundario"},{"name":"action","type":"React.ReactNode","required":false,"description":"Accion opcional (boton, link) debajo del contenido"},{"name":"onClose","type":"() =\u003e void","required":false,"description":"Callback al cerrar. Muestra el boton X si se provee."},{"name":"duration","type":"number","required":false,"description":"Duracion en ms antes del auto-dismiss (default: 5000, 0 = persistente)"}]

notes:
  Migrado a @mantine/notifications. useToast() delega a notifications.show(). ToastProvider es un passthrough (Mantine maneja el estado globalmente via Notifications en mantine_provider). Toast puede usarse como componente inline con Paper. Variantes mapean a colores Mantine: success→teal, error→red, warning→yellow, info→blue.

code:
  import { notifications } from '@mantine/notifications'
  import { Paper, Text, Group, CloseButton } from '@mantine/core'

  type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info'

  interface ToastProps {
    variant?: ToastVariant
    title?: string
    description?: string
    action?: React.ReactNode
    onClose?: () => void
    className?: string
    children?: React.ReactNode
  }

  const variantColors: Record<ToastVariant, string | undefined> = {
    default: undefined,
    success: 'teal',
    error: 'red',
    warning: 'yellow',
    info: 'blue',
  }

  function Toast({ title, description, onClose, className }: ToastProps) {
    return (
      <Paper withBorder shadow="md" p="md" radius="md" className={className}>
        <Group justify="space-between" align="flex-start">
          <div>
            {title && <Text size="sm" fw={600}>{title}</Text>}
            {description && <Text size="sm" c="dimmed">{description}</Text>}
          </div>
          {onClose && <CloseButton size="sm" onClick={onClose} />}
        </Group>
      </Paper>
    )
  }

  const toastVariants = {
    default: 'default',
    success: 'success',
    error: 'error',
    warning: 'warning',
    info: 'info',
  } as const

  interface ToastViewportProps {
    position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
    children?: React.ReactNode
    className?: string
  }

  function ToastViewport({ children }: ToastViewportProps) {
    return <>{children}</>
  }

  type ToastEntry = ToastProps & {
    id: string
    duration?: number
  }

  interface ToastProviderContextValue {
    toasts: ToastEntry[]
    toast: (props: Omit<ToastEntry, 'id'>) => string
    dismiss: (id: string) => void
    dismissAll: () => void
  }

  function ToastProvider({ children }: { children: React.ReactNode; position?: ToastViewportProps['position'] }) {
    return <>{children}</>
  }

  function useToast(): ToastProviderContextValue {
    return {
      toasts: [],
      toast: (props: Omit<ToastEntry, 'id'>) => {
        const id = Math.random().toString(36).slice(2)
        notifications.show({
          id,
          title: props.title,
          message: props.description ?? '',
          color: variantColors[props.variant ?? 'default'],
          autoClose: props.duration === 0 ? false : (props.duration ?? 5000),
        })
        return id
      },
      dismiss: (id: string) => {
        notifications.hide(id)
      },
      dismissAll: () => {
        notifications.cleanQueue()
        notifications.clean()
      },
    }
  }

  export { Toast, ToastProvider, ToastViewport, toastVariants, useToast }
  export type { ToastEntry, ToastProps, ToastViewportProps }

params_schema:
  {"output":"Componente Toast que renderiza notificación temporal con variantes semánticas, auto-dismiss y hook useToast"}


id: tooltip_ts_ui
name: tooltip
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: Tooltip(props: TooltipRootProps): JSX.Element
description: Tooltip accesible con posicionamiento automático. Mantine Tooltip con delay configurable.
tags: ["tooltip","overlay","component","ui","help","mantine"]
uses_functions: []
uses_types: []
returns: []
imports: ["@mantine/core","react"]
tests: []
file_path: frontend/functions/ui/tooltip.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-06T00:00:55Z
emits: []
framework: react
variant: ["default"]
content_hash: 950fc5821f3c8aff9e888d8dbc9cfe35ded5dd1987a53d0524ff88eda2195307
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/components/ui/tooltip.tsx
example:
  ```tsx
  <TooltipProvider>
    <Tooltip>
      <TooltipTrigger>Hover me</TooltipTrigger>
      <TooltipContent>Tooltip text</TooltipContent>
    </Tooltip>
  </TooltipProvider>
  ```

props:
  [{"name":"delayDuration","type":"number","required":false,"description":"Delay en ms antes de mostrar el tooltip (default 300)"}]

notes:
  Exporta 5 subcomponentes: TooltipProvider, Tooltip, TooltipTrigger, TooltipPortal, TooltipContent.
  TooltipProvider y Tooltip son wrappers de compatibilidad (Mantine gestiona delay internamente).
  TooltipPortal es un no-op mantenido por compatibilidad.

code:
  import * as React from 'react'
  import { Tooltip as MantineTooltip } from '@mantine/core'

  function TooltipProvider({ delayDuration: _delayDuration = 300, children }: { delayDuration?: number; children: React.ReactNode }) {
    return <>{children}</>
  }

  const TooltipContext = React.createContext<{
    label: React.ReactNode
    setLabel: (label: React.ReactNode) => void
    side: 'top' | 'right' | 'bottom' | 'left'
    setSide: (side: 'top' | 'right' | 'bottom' | 'left') => void
    offset: number
    setOffset: (offset: number) => void
  }>({
    label: '',
    setLabel: () => {},
    side: 'top',
    setSide: () => {},
    offset: 4,
    setOffset: () => {},
  })

  interface TooltipProps {
    children: React.ReactNode
    delayDuration?: number
  }

  function Tooltip({ children }: TooltipProps) {
    const [label, setLabel] = React.useState<React.ReactNode>('')
    const [side, setSide] = React.useState<'top' | 'right' | 'bottom' | 'left'>('top')
    const [offset, setOffset] = React.useState(4)

    return (
      <TooltipContext.Provider value={{ label, setLabel, side, setSide, offset, setOffset }}>
        {children}
      </TooltipContext.Provider>
    )
  }

  function TooltipTrigger({ children, ...props }: React.ComponentProps<'button'>) {
    const { label, side, offset } = React.useContext(TooltipContext)
    return (
      <MantineTooltip label={label} position={side} offset={offset} withArrow>
        {React.isValidElement(children) ? (
          React.cloneElement(children as React.ReactElement<any>, props)
        ) : (
          <button type="button" data-slot="tooltip-trigger" {...props}>{children}</button>
        )}
      </MantineTooltip>
    )
  }

  function TooltipPortal({ children }: { children: React.ReactNode }) {
    return <>{children}</>
  }

  interface TooltipContentProps extends React.ComponentProps<'div'> {
    sideOffset?: number
    side?: 'top' | 'right' | 'bottom' | 'left'
  }

  function TooltipContent({ children, sideOffset, side = 'top' }: TooltipContentProps) {
    const { setLabel, setSide, setOffset } = React.useContext(TooltipContext)

    React.useEffect(() => {
      setLabel(children)
      setSide(side)
      if (sideOffset !== undefined) setOffset(sideOffset)
    }, [children, side, sideOffset, setLabel, setSide, setOffset])

    return null
  }

  export { Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger }

params_schema:
  {"output":"Componente Tooltip que renderiza tooltip accesible con posicionamiento automático y delay configurable via Mantine Tooltip"}


id: use_animated_canvas_ts_ui
name: use_animated_canvas
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: useAnimatedCanvas(options: AnimatedCanvasOptions): AnimatedCanvasResult
tags: ["canvas","animation","fps","requestAnimationFrame","hook","realtime","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/use_animated_canvas.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
emits: []
framework: react
variant: ["default"]
content_hash: 9157ee07fe43dbb9679c63c670e2308b9fa96a41cb0a4e9b85d693d07f92f9da
description:
  Hook React para canvas animado a N fps via requestAnimationFrame. Maneja DPR, resize, throttling, y contador de FPS real.

example:
  ```tsx
  const { canvasRef, renderFpsRef } = useAnimatedCanvas({
    fps: 100,
    draw: (ctx, w, h) => {
      // Dibujar lo que sea a 100fps
      ctx.fillStyle = '#3b82f6'
      ctx.fillRect(0, 0, w * Math.random(), h)
    },
  })

  return <canvas ref={canvasRef} style={{ width: '100%', height: 300 }} />
  ```

props:
  [{"name":"fps","type":"number","required":false,"description":"Target FPS (default 60)"},{"name":"draw","type":"(ctx: CanvasRenderingContext2D, width: number, height: number, frameCount: number) =\u003e void","required":true,"description":"Callback de renderizado"}]

notes:
  - DPR automático: escala el canvas al devicePixelRatio de la pantalla
  - Resize automático: detecta cambios de tamaño via getBoundingClientRect
  - Throttle configurable: rAF corre a ~144fps nativo, el hook filtra a N fps
  - FPS real: `renderFpsRef.current` tiene los FPS medidos (no el target)
  - drawRef pattern: actualiza el callback sin re-crear el loop de animación

code:
  import { useEffect, useRef } from 'react'

  export interface AnimatedCanvasOptions {
    /** Target FPS (default 60). El throttle real es 1000/fps ms. */
    fps?: number
    /** Callback de dibujo. Recibe el canvas 2d context y dimensiones. */
    draw: (ctx: CanvasRenderingContext2D, width: number, height: number, frameCount: number) => void
  }

  export interface AnimatedCanvasResult {
    canvasRef: React.RefObject<HTMLCanvasElement | null>
    /** FPS real de renderizado (actualizado cada segundo) */
    renderFpsRef: React.RefObject<number>
  }

  /**
   * Hook para renderizar un canvas a N fps usando requestAnimationFrame.
   * Maneja DPR, resize, y throttling automático.
   */
  export function useAnimatedCanvas(options: AnimatedCanvasOptions): AnimatedCanvasResult {
    const { fps = 60, draw } = options
    const canvasRef = useRef<HTMLCanvasElement>(null)
    const renderFpsRef = useRef(0)
    const rafRef = useRef(0)
    const lastDrawRef = useRef(0)
    const frameCountRef = useRef(0)
    const fpsTimerRef = useRef(0)
    const drawRef = useRef(draw)

    // Keep draw ref updated without re-subscribing the effect
    drawRef.current = draw

    useEffect(() => {
      const interval = 1000 / fps

      const loop = (now: number) => {
        rafRef.current = requestAnimationFrame(loop)

        if (now - lastDrawRef.current < interval) return
        lastDrawRef.current = now

        // FPS counting
        frameCountRef.current++
        if (now - fpsTimerRef.current >= 1000) {
          renderFpsRef.current = frameCountRef.current
          frameCountRef.current = 0
          fpsTimerRef.current = now
        }

        const canvas = canvasRef.current
        if (!canvas) return

        const rect = canvas.getBoundingClientRect()
        const w = rect.width
        const h = rect.height
        const dpr = window.devicePixelRatio || 1

        const targetW = Math.floor(w * dpr)
        const targetH = Math.floor(h * dpr)

        if (canvas.width !== targetW || canvas.height !== targetH) {
          canvas.width = targetW
          canvas.height = targetH
        }

        const ctx = canvas.getContext('2d')
        if (!ctx) return

        ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
        ctx.clearRect(0, 0, w, h)

        drawRef.current(ctx, w, h, frameCountRef.current)
      }

      rafRef.current = requestAnimationFrame(loop)
      return () => cancelAnimationFrame(rafRef.current)
    }, [fps])

    return { canvasRef, renderFpsRef }
  }

params_schema:
  {"params":[{"name":"options","desc":"Configuración: fps (target), draw (callback de renderizado), opcionalmente DPR"}],"output":"Hook que retorna canvasRef y renderFpsRef para renderizado animado a N fps"}


id: use_wails_event_ts_ui
name: use_wails_event
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: useWailsEvent<T>(opts: UseWailsEventOptions<T>): UseWailsEventResult<T>
tags: ["wails","event","hook","ipc","realtime","component","ui"]
uses_functions: []
uses_types: []
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/use_wails_event.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
emits: ["onEvent"]
framework: react
variant: ["default"]
content_hash: 23f5fefca880d48b8d2fb61a0cb7a544595a43ba7b8e383393ce1d7a7a1a0457
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/lib/wails/use-wails-event.tsx
description:
  Hook para suscripción a eventos Go→TS y emisión TS→Go via Wails runtime. Soporta once, maxCallbacks, emit bidireccional.

example:
  ```tsx
  // Escuchar eventos de Go
  const { lastData, eventCount, emit } = useWailsEvent<PriceUpdate>({
    eventName: 'price:update',
    onEvent: (price) => updateChart(price),
  })

  // Emitir de TS a Go
  emit({ symbol: 'BTC', action: 'subscribe' })
  ```

props:
  [{"name":"eventName","type":"string","required":true,"description":"Nombre del evento Wails"},{"name":"onEvent","type":"(data: T) =\u003e void","required":false,"description":"Callback al recibir evento"},{"name":"once","type":"boolean","required":false,"description":"Solo escuchar una vez"},{"name":"enabled","type":"boolean","required":false,"description":"Habilitar suscripción"}]

notes:
  Exporta también `useWailsEmit()` para emit sin suscripción. Usa `window.runtime.EventsOn/Off/Emit` del Wails runtime.

code:
  import { useCallback, useEffect, useRef, useState } from 'react'

  // Types for Wails runtime (will be available when running in Wails)
  declare global {
    interface Window {
      runtime?: {
        EventsOn: (eventName: string, callback: (...args: unknown[]) => void) => () => void
        EventsOff: (eventName: string, ...additionalEventNames: string[]) => void
        EventsOnce: (eventName: string, callback: (...args: unknown[]) => void) => () => void
        EventsOnMultiple: (eventName: string, callback: (...args: unknown[]) => void, maxCallbacks: number) => () => void
        EventsEmit: (eventName: string, ...data: unknown[]) => void
      }
    }
  }

  export interface UseWailsEventOptions<T> {
    /** Nombre del evento a escuchar */
    eventName: string
    /** Callback cuando llega el evento */
    onEvent?: (data: T) => void
    /** Si está habilitado */
    enabled?: boolean
    /** Solo escuchar una vez */
    once?: boolean
    /** Número máximo de veces a escuchar */
    maxCallbacks?: number
  }

  export interface UseWailsEventResult<T> {
    /** Último dato recibido */
    lastData: T | undefined
    /** Número de eventos recibidos */
    eventCount: number
    /** Emitir un evento */
    emit: (data?: T) => void
    /** Resetear estado */
    reset: () => void
  }

  /**
   * Hook para suscribirse a eventos de Wails
   */
  export function useWailsEvent<T = unknown>({
    eventName,
    onEvent,
    enabled = true,
    once = false,
    maxCallbacks,
  }: UseWailsEventOptions<T>): UseWailsEventResult<T> {
    const [lastData, setLastData] = useState<T | undefined>(undefined)
    const [eventCount, setEventCount] = useState(0)
    const callbackRef = useRef(onEvent)

    // Keep callback ref updated
    useEffect(() => {
      callbackRef.current = onEvent
    }, [onEvent])

    useEffect(() => {
      if (!enabled || !window.runtime) return

      const handleEvent = (...args: unknown[]) => {
        const data = args[0] as T
        setLastData(data)
        setEventCount((c) => c + 1)
        callbackRef.current?.(data)
      }

      let unsubscribe: (() => void) | undefined

      if (once) {
        unsubscribe = window.runtime.EventsOnce(eventName, handleEvent)
      } else if (maxCallbacks !== undefined) {
        unsubscribe = window.runtime.EventsOnMultiple(eventName, handleEvent, maxCallbacks)
      } else {
        unsubscribe = window.runtime.EventsOn(eventName, handleEvent)
      }

      return () => {
        unsubscribe?.()
      }
    }, [eventName, enabled, once, maxCallbacks])

    const emit = useCallback(
      (data?: T) => {
        if (window.runtime) {
          window.runtime.EventsEmit(eventName, data)
        }
      },
      [eventName]
    )

    const reset = useCallback(() => {
      setLastData(undefined)
      setEventCount(0)
    }, [])

    return {
      lastData,
      eventCount,
      emit,
      reset,
    }
  }

  /**
   * Hook para emitir eventos a Wails (sin suscripción)
   */
  export function useWailsEmit() {
    return useCallback((eventName: string, ...data: unknown[]) => {
      if (window.runtime) {
        window.runtime.EventsEmit(eventName, ...data)
      }
    }, [])
  }

params_schema:
  {"params":[{"name":"opts","desc":"Configuración: eventName, onEvent callback, once, enabled para controlar suscripción"}],"output":"Hook que retorna lastData, eventCount, emit y funciones para suscribirse a eventos Wails bidireccionales"}


id: use_wails_mutation_ts_ui
name: use_wails_mutation
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
tags: ["wails","mutation","hook","ipc","optimistic","component","ui"]
uses_functions: ["wails_cache_ts_core","wails_provider_ts_ui"]
uses_types: ["WailsIPC_ts_ui"]
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/use_wails_mutation.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
emits: ["onSuccess","onError","onSettled"]
framework: react
variant: ["default"]
content_hash: 448f91cfb5425fa5fcfc2d56f1d20b61bb157b9dbbb79ef6fa85af805d9d58c2
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/lib/wails/use-wails-mutation.tsx
signature:
  useWailsMutation<TData, TVariables>(opts: UseWailsMutationOptions<TData, TVariables>): UseWailsMutationResult<TData, TVariables>

description:
  Hook para escrituras IPC Wails con optimistic updates, invalidación automática de queries, retry y callbacks completos.

example:
  ```tsx
  const { mutate, isLoading } = useWailsMutation({
    mutationFn: (user: User) => CreateUser(user),
    invalidateQueries: [['users']],
    onMutate: (user) => {
      // Optimistic: añadir al cache antes de confirmar
      const prev = wailsCache.get<User[]>(['users', 'list'])
      wailsCache.set(['users', 'list'], [...(prev || []), user])
      return { prev }
    },
    onError: (_err, _vars, context) => {
      // Rollback
      wailsCache.set(['users', 'list'], context.prev)
    },
  })
  ```

props:
  [{"name":"mutationFn","type":"(variables: TVariables) =\u003e Promise\u003cTData\u003e","required":true,"description":"Función que ejecuta la mutación via IPC"},{"name":"invalidateQueries","type":"string[][]","required":false,"description":"Query keys a invalidar en éxito"},{"name":"onMutate","type":"(variables: TVariables) =\u003e unknown","required":false,"description":"Optimistic update antes de la mutación"}]

notes:
  `mutate()` es fire-and-forget, `mutateAsync()` retorna Promise. Estado: idle → loading → success/error.

code:
  import { useCallback, useRef, useState } from 'react'
  import { useWailsContext } from './wails_provider'
  import type { MutationOptions } from '../../types/ui/wails_ipc'

  export interface UseWailsMutationOptions<TData, TVariables> extends MutationOptions {
    /** Función que ejecuta la mutación */
    mutationFn: (variables: TVariables) => Promise<TData>
    /** Callback antes de la mutación (optimistic update) */
    onMutate?: (variables: TVariables) => Promise<unknown> | unknown
    /** Callback en éxito */
    onSuccess?: (data: TData, variables: TVariables, context: unknown) => void
    /** Callback en error */
    onError?: (error: Error, variables: TVariables, context: unknown) => void
    /** Callback siempre (éxito o error) */
    onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables, context: unknown) => void
    /** Query keys a invalidar en éxito */
    invalidateQueries?: string[][]
  }

  export interface UseWailsMutationResult<TData, TVariables> {
    /** Ejecutar la mutación */
    mutate: (variables: TVariables) => void
    /** Ejecutar la mutación (async) */
    mutateAsync: (variables: TVariables) => Promise<TData>
    /** Estado de carga */
    isLoading: boolean
    /** Datos del resultado */
    data: TData | undefined
    /** Error si ocurrió */
    error: Error | null
    /** Resetear estado */
    reset: () => void
    /** Si fue exitoso */
    isSuccess: boolean
    /** Si hubo error */
    isError: boolean
    /** Si está idle */
    isIdle: boolean
  }

  export function useWailsMutation<TData, TVariables = void>({
    mutationFn,
    onMutate,
    onSuccess,
    onError,
    onSettled,
    invalidateQueries,
    retry = false,
    retryDelay = 1000,
  }: UseWailsMutationOptions<TData, TVariables>): UseWailsMutationResult<TData, TVariables> {
    const { cache } = useWailsContext()

    const [state, setState] = useState<{
      status: 'idle' | 'loading' | 'success' | 'error'
      data: TData | undefined
      error: Error | null
    }>({
      status: 'idle',
      data: undefined,
      error: null,
    })

    const retryCountRef = useRef(0)

    const executeMutation = useCallback(
      async (variables: TVariables): Promise<TData> => {
        setState((s) => ({ ...s, status: 'loading', error: null }))

        let context: unknown

        try {
          // Optimistic update
          if (onMutate) {
            context = await onMutate(variables)
          }

          const data = await mutationFn(variables)

          // Reset retry count on success
          retryCountRef.current = 0

          setState({
            status: 'success',
            data,
            error: null,
          })

          // Invalidate queries
          if (invalidateQueries) {
            invalidateQueries.forEach((queryKey) => {
              cache.invalidate(queryKey)
            })
          }

          onSuccess?.(data, variables, context)
          onSettled?.(data, null, variables, context)

          return data
        } catch (error) {
          const maxRetries = typeof retry === 'number' ? retry : retry ? 3 : 0

          if (retryCountRef.current < maxRetries) {
            retryCountRef.current += 1
            await new Promise((resolve) => setTimeout(resolve, retryDelay))
            return executeMutation(variables)
          }

          setState((s) => ({
            ...s,
            status: 'error',
            error: error as Error,
          }))

          onError?.(error as Error, variables, context)
          onSettled?.(undefined, error as Error, variables, context)

          throw error
        }
      },
      [mutationFn, onMutate, onSuccess, onError, onSettled, invalidateQueries, cache, retry, retryDelay]
    )

    const mutate = useCallback(
      (variables: TVariables) => {
        executeMutation(variables).catch(() => {})
      },
      [executeMutation]
    )

    const reset = useCallback(() => {
      setState({
        status: 'idle',
        data: undefined,
        error: null,
      })
      retryCountRef.current = 0
    }, [])

    return {
      mutate,
      mutateAsync: executeMutation,
      isLoading: state.status === 'loading',
      data: state.data,
      error: state.error,
      reset,
      isSuccess: state.status === 'success',
      isError: state.status === 'error',
      isIdle: state.status === 'idle',
    }
  }

params_schema:
  {"params":[{"name":"opts","desc":"Configuración: mutationFn, invalidateQueries, onMutate para optimistic updates"}],"output":"Hook que retorna mutate, mutateAsync, isLoading, error para ejecutar escrituras IPC con cache"}


id: use_wails_query_ts_ui
name: use_wails_query
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: useWailsQuery<T>(opts: UseWailsQueryOptions<T>): UseWailsQueryResult<T>
tags: ["wails","query","hook","ipc","cache","component","ui"]
uses_functions: ["wails_cache_ts_core","wails_provider_ts_ui"]
uses_types: ["WailsIPC_ts_ui"]
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/use_wails_query.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
emits: ["onSuccess","onError"]
framework: react
variant: ["default"]
content_hash: c4352f02934ed80e92fc4753a052848e7066a3da04d67ef2521882c9d90208af
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/lib/wails/use-wails-query.tsx
description:
  Hook React Query-like sobre IPC Wails. Cache automático, refetch por intervalo/foco, retry con backoff, invalidación.

example:
  ```tsx
  const { data, loading, refetch, invalidate } = useWailsQuery({
    queryKey: ['users', 'list'],
    queryFn: () => GetUsers(),       // Wails binding
    refetchInterval: 30000,          // Cada 30s
    staleTime: 5000,                 // Fresh por 5s
    onSuccess: (users) => console.log(`${users.length} users`),
  })
  ```

props:
  [{"name":"queryKey","type":"string[]","required":true,"description":"Key única para cache"},{"name":"queryFn","type":"() =\u003e Promise\u003cT\u003e","required":true,"description":"Función que llama al binding Wails"},{"name":"enabled","type":"boolean","required":false,"description":"Habilitar auto-fetch (default true)"},{"name":"refetchInterval","type":"number","required":false,"description":"Refetch cada N ms"},{"name":"staleTime","type":"number","required":false,"description":"Tiempo antes de considerar datos stale"}]

notes:
  API inspirada en TanStack Query pero sobre IPC Wails (sin HTTP). Cache compartido via WailsProvider o singleton. Refetch automático en window focus.

code:
  import { useCallback, useEffect, useRef, useState } from 'react'
  import { useWailsContext } from './wails_provider'
  import { DEFAULT_QUERY_OPTIONS, type QueryOptions, type QueryState } from '../../types/ui/wails_ipc'

  export interface UseWailsQueryOptions<T> extends QueryOptions {
    /** Key única para identificar esta query en el cache */
    queryKey: string[]
    /** Función que ejecuta la llamada a Wails */
    queryFn: () => Promise<T>
    /** Callback en éxito */
    onSuccess?: (data: T) => void
    /** Callback en error */
    onError?: (error: Error) => void
  }

  export interface UseWailsQueryResult<T> extends QueryState<T> {
    /** Re-ejecutar la query manualmente */
    refetch: () => Promise<T>
    /** Invalidar cache de esta query */
    invalidate: () => void
  }

  export function useWailsQuery<T>({
    queryKey,
    queryFn,
    onSuccess,
    onError,
    ...options
  }: UseWailsQueryOptions<T>): UseWailsQueryResult<T> {
    const { cache, defaultQueryOptions } = useWailsContext()

    // Merge options with defaults
    const opts = {
      ...DEFAULT_QUERY_OPTIONS,
      ...defaultQueryOptions,
      ...options,
    }

    const {
      enabled,
      staleTime,
      refetchInterval,
      refetchOnFocus,
      retry,
      retryDelay,
    } = opts

    const [state, setState] = useState<QueryState<T>>(() => ({
      data: cache.get<T>(queryKey),
      loading: !cache.has(queryKey) && enabled,
      error: null,
      isStale: cache.isStale(queryKey, staleTime),
      lastUpdated: cache.getTimestamp(queryKey),
    }))

    const retryCountRef = useRef(0)
    const mountedRef = useRef(true)

    const fetch = useCallback(async (): Promise<T> => {
      if (!mountedRef.current) throw new Error('Component unmounted')

      setState((s) => ({ ...s, loading: true, error: null }))

      try {
        const data = await queryFn()

        if (!mountedRef.current) throw new Error('Component unmounted')

        cache.set(queryKey, data)
        retryCountRef.current = 0

        setState({
          data,
          loading: false,
          error: null,
          isStale: false,
          lastUpdated: new Date(),
        })

        onSuccess?.(data)
        return data
      } catch (error) {
        if (!mountedRef.current) throw error

        const maxRetries = typeof retry === 'number' ? retry : retry ? 3 : 0

        if (retryCountRef.current < maxRetries) {
          retryCountRef.current += 1
          await new Promise((resolve) => setTimeout(resolve, retryDelay))
          return fetch()
        }

        setState((s) => ({
          ...s,
          loading: false,
          error: error as Error,
        }))

        onError?.(error as Error)
        throw error
      }
    }, [queryKey.join(':'), queryFn, cache, staleTime, retry, retryDelay, onSuccess, onError])

    // Initial fetch
    useEffect(() => {
      mountedRef.current = true

      if (enabled && (!cache.has(queryKey) || cache.isStale(queryKey, staleTime))) {
        fetch().catch(() => {})
      }

      return () => {
        mountedRef.current = false
      }
    }, [enabled, queryKey.join(':'), staleTime])

    // Refetch interval
    useEffect(() => {
      if (!refetchInterval || !enabled) return

      const interval = setInterval(() => {
        fetch().catch(() => {})
      }, refetchInterval)

      return () => clearInterval(interval)
    }, [refetchInterval, enabled, fetch])

    // Refetch on window focus
    useEffect(() => {
      if (!refetchOnFocus || !enabled) return

      const handleFocus = () => {
        if (cache.isStale(queryKey, staleTime)) {
          fetch().catch(() => {})
        }
      }

      window.addEventListener('focus', handleFocus)
      return () => window.removeEventListener('focus', handleFocus)
    }, [refetchOnFocus, enabled, queryKey.join(':'), staleTime, fetch])

    // Subscribe to cache changes
    useEffect(() => {
      return cache.subscribe(queryKey, () => {
        setState((s) => ({
          ...s,
          data: cache.get<T>(queryKey),
          isStale: cache.isStale(queryKey, staleTime),
          lastUpdated: cache.getTimestamp(queryKey),
        }))
      })
    }, [queryKey.join(':'), staleTime])

    const invalidate = useCallback(() => {
      cache.invalidate(queryKey)
    }, [queryKey.join(':')])

    return {
      ...state,
      refetch: fetch,
      invalidate,
    }
  }

params_schema:
  {"params":[{"name":"opts","desc":"Configuración: queryKey para cache, queryFn que ejecuta binding Wails, enabled, refetchInterval, staleTime"}],"output":"Hook que retorna data, loading, error, refetch, invalidate para queries cacheadas vía IPC"}


id: use_wails_stream_ts_ui
name: use_wails_stream
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
signature: useWailsStream<T>(opts: UseWailsStreamOptions<T>): UseWailsStreamResult<T>
tags: ["wails","stream","hook","ipc","realtime","buffer","component","ui"]
uses_functions: ["use_wails_event_ts_ui"]
uses_types: []
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/use_wails_stream.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
emits: ["onData","onComplete","onError"]
framework: react
variant: ["default"]
content_hash: 89ab2e113edf401405c222a9adf7e55ff3d2ea28dce3ecd7fa7ebef65a27992a
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/lib/wails/use-wails-stream.tsx
description:
  Hook para streaming de datos Go→TS con buffer configurable, auto-complete, transform y control start/stop. Incluye useWailsLogs.

example:
  ```tsx
  // Stream de logs en tiempo real
  const { data: logs, isStreaming, start, stop, clear } = useWailsStream<string>({
    streamName: 'app:logs',
    startFn: () => StartLogStream(),
    stopFn: () => StopLogStream(),
    bufferSize: 500,
    autoStart: true,
  })

  // Versión simplificada para logs
  const { data: logs } = useWailsLogs('deploy:logs')
  ```

props:
  [{"name":"streamName","type":"string","required":true,"description":"Nombre del evento de stream"},{"name":"startFn","type":"() =\u003e Promise\u003cvoid\u003e","required":false,"description":"Función Go para iniciar el stream"},{"name":"stopFn","type":"() =\u003e Promise\u003cvoid\u003e","required":false,"description":"Función Go para detener el stream"},{"name":"bufferSize","type":"number","required":false,"description":"Tamaño máximo del buffer (default 1000)"},{"name":"transform","type":"(data: unknown) =\u003e T","required":false,"description":"Transformar datos entrantes"}]

notes:
  Protocolo de stream: Go emite chunks en `{streamName}`, completa con `{streamName}:complete`, errores con `{streamName}:error`. Buffer circular con tamaño configurable.

code:
  import { useCallback, useEffect, useRef, useState } from 'react'
  import { useWailsEvent } from './use_wails_event'

  export interface UseWailsStreamOptions<T> {
    /** Nombre del evento de stream */
    streamName: string
    /** Función para iniciar el stream en Go */
    startFn?: () => Promise<void>
    /** Función para detener el stream en Go */
    stopFn?: () => Promise<void>
    /** Transformar datos entrantes */
    transform?: (data: unknown) => T
    /** Callback por cada chunk */
    onData?: (data: T) => void
    /** Callback cuando el stream termina */
    onComplete?: () => void
    /** Callback en error */
    onError?: (error: Error) => void
    /** Tamaño máximo del buffer */
    bufferSize?: number
    /** Auto-iniciar el stream */
    autoStart?: boolean
  }

  export interface UseWailsStreamResult<T> {
    /** Datos acumulados */
    data: T[]
    /** Último dato recibido */
    lastChunk: T | undefined
    /** Si está activo */
    isStreaming: boolean
    /** Si está cargando (iniciando) */
    isLoading: boolean
    /** Error si ocurrió */
    error: Error | null
    /** Iniciar stream */
    start: () => Promise<void>
    /** Detener stream */
    stop: () => Promise<void>
    /** Limpiar buffer */
    clear: () => void
    /** Número de chunks recibidos */
    chunkCount: number
  }

  /**
   * Hook para manejar streams de datos desde Wails
   * Útil para datos en tiempo real como logs, métricas, etc.
   */
  export function useWailsStream<T = unknown>({
    streamName,
    startFn,
    stopFn,
    transform,
    onData,
    onComplete,
    onError,
    bufferSize = 1000,
    autoStart = false,
  }: UseWailsStreamOptions<T>): UseWailsStreamResult<T> {
    const [data, setData] = useState<T[]>([])
    const [isStreaming, setIsStreaming] = useState(false)
    const [isLoading, setIsLoading] = useState(false)
    const [error, setError] = useState<Error | null>(null)
    const [chunkCount, setChunkCount] = useState(0)

    const lastChunkRef = useRef<T | undefined>(undefined)
    const mountedRef = useRef(true)

    // Listen for stream data
    useWailsEvent<unknown>({
      eventName: streamName,
      enabled: isStreaming,
      onEvent: (rawData) => {
        if (!mountedRef.current) return

        const chunk = transform ? transform(rawData) : (rawData as T)
        lastChunkRef.current = chunk

        setData((prev) => {
          const newData = [...prev, chunk]
          // Mantener buffer limitado
          if (newData.length > bufferSize) {
            return newData.slice(-bufferSize)
          }
          return newData
        })

        setChunkCount((c) => c + 1)
        onData?.(chunk)
      },
    })

    // Listen for stream complete
    useWailsEvent({
      eventName: `${streamName}:complete`,
      enabled: isStreaming,
      onEvent: () => {
        if (!mountedRef.current) return
        setIsStreaming(false)
        onComplete?.()
      },
    })

    // Listen for stream error
    useWailsEvent<{ message: string }>({
      eventName: `${streamName}:error`,
      enabled: isStreaming,
      onEvent: (errorData) => {
        if (!mountedRef.current) return
        const err = new Error(errorData?.message || 'Stream error')
        setError(err)
        setIsStreaming(false)
        onError?.(err)
      },
    })

    const start = useCallback(async () => {
      if (isStreaming || isLoading) return

      setIsLoading(true)
      setError(null)

      try {
        if (startFn) {
          await startFn()
        }
        setIsStreaming(true)
      } catch (err) {
        const error = err as Error
        setError(error)
        onError?.(error)
      } finally {
        setIsLoading(false)
      }
    }, [isStreaming, isLoading, startFn, onError])

    const stop = useCallback(async () => {
      if (!isStreaming) return

      try {
        if (stopFn) {
          await stopFn()
        }
        setIsStreaming(false)
      } catch (err) {
        const error = err as Error
        setError(error)
        onError?.(error)
      }
    }, [isStreaming, stopFn, onError])

    const clear = useCallback(() => {
      setData([])
      setChunkCount(0)
      lastChunkRef.current = undefined
    }, [])

    // Auto-start
    useEffect(() => {
      if (autoStart) {
        start()
      }
    }, []) // eslint-disable-line react-hooks/exhaustive-deps

    // Cleanup
    useEffect(() => {
      mountedRef.current = true
      return () => {
        mountedRef.current = false
      }
    }, [])

    return {
      data,
      lastChunk: lastChunkRef.current,
      isStreaming,
      isLoading,
      error,
      start,
      stop,
      clear,
      chunkCount,
    }
  }

  /**
   * Hook simplificado para logs en tiempo real
   */
  export function useWailsLogs(eventName: string = 'logs') {
    return useWailsStream<string>({
      streamName: eventName,
      transform: (data) => (typeof data === 'string' ? data : JSON.stringify(data)),
      bufferSize: 500,
    })
  }

params_schema:
  {"params":[{"name":"opts","desc":"Configuración: streamName, startFn/stopFn para control, bufferSize, transform para procesamiento"}],"output":"Hook que retorna data (array bufferado), isStreaming, start, stop, clear para streaming Go→TS"}


id: wails_provider_ts_ui
name: wails_provider
kind: component
lang: ts
domain: ui
version: 1.0.0
purity: impure
tags: ["wails","provider","context","ipc","component","ui"]
uses_functions: ["wails_cache_ts_core"]
uses_types: ["WailsIPC_ts_ui"]
returns: []
imports: ["react"]
tests: []
file_path: frontend/functions/ui/wails_provider.tsx
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
emits: []
framework: react
variant: ["default"]
content_hash: 73c511b9e730dfc299b734e3a7f5d222d6727459d90c70d1af1b1d4dfeca9eae
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library
source_license: MIT
source_file: frontend/src/lib/wails/wails-provider.tsx
signature:
  WailsProvider(props: { children: ReactNode; cache?: WailsCache; defaultQueryOptions?: QueryOptions }): JSX.Element

description:
  Provider React para IPC Wails con cache context, opciones default y fallback a singleton. Exporta useWailsContext y useWailsCache.

example:
  ```tsx
  <WailsProvider defaultQueryOptions={{ staleTime: 5000, retry: 2 }}>
    <App />
  </WailsProvider>
  ```

props:
  [{"name":"cache","type":"WailsCache","required":false,"description":"Cache custom (default: singleton global)"},{"name":"defaultQueryOptions","type":"Partial\u003cQueryOptions\u003e","required":false,"description":"Opciones default para todas las queries"},{"name":"children","type":"ReactNode","required":true,"description":"App content"}]

notes:
  Sin provider, los hooks hacen fallback al singleton `wailsCache`. El provider solo es necesario para cache custom o opciones globales.

code:
  import { createContext, useContext, useMemo, type ReactNode } from 'react'
  import { WailsCache, wailsCache } from '../core/wails_cache'
  import type { QueryOptions, MutationOptions } from '../../types/ui/wails_ipc'

  interface WailsContextValue {
    cache: WailsCache
    defaultQueryOptions: Partial<QueryOptions>
    defaultMutationOptions: Partial<MutationOptions>
  }

  const WailsContext = createContext<WailsContextValue | null>(null)

  export interface WailsProviderProps {
    children: ReactNode
    /** Usar un cache custom en lugar del singleton */
    cache?: WailsCache
    /** Opciones por defecto para queries */
    defaultQueryOptions?: Partial<QueryOptions>
    /** Opciones por defecto para mutations */
    defaultMutationOptions?: Partial<MutationOptions>
  }

  export function WailsProvider({
    children,
    cache,
    defaultQueryOptions = {},
    defaultMutationOptions = {},
  }: WailsProviderProps) {
    const value = useMemo<WailsContextValue>(
      () => ({
        cache: cache ?? wailsCache,
        defaultQueryOptions,
        defaultMutationOptions,
      }),
      [cache, defaultQueryOptions, defaultMutationOptions]
    )

    return (
      <WailsContext.Provider value={value}>
        {children}
      </WailsContext.Provider>
    )
  }

  export function useWailsContext(): WailsContextValue {
    const context = useContext(WailsContext)
    if (!context) {
      // Fallback to singleton cache if no provider
      return {
        cache: wailsCache,
        defaultQueryOptions: {},
        defaultMutationOptions: {},
      }
    }
    return context
  }

  export function useWailsCache(): WailsCache {
    return useWailsContext().cache
  }

params_schema:
  {"output":"Componente WailsProvider que proporciona context de cache y opciones default para hooks Wails"}


id: build_guide_prompt_kt_core
name: build_guide_prompt
kind: function
lang: kt
domain: core
version: 1.0.0
purity: pure
tags: ["llm","prompt","guide","location","poi","chat","openai","ollama","voice"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: kotlin/functions/core/build_guide_prompt.kt
created_at: 2026-04-06T16:55:26Z
updated_at: 2026-04-06T16:55:26Z
props: []
emits: []
variant: []
content_hash: 8d279fc604f32bdf5a717f433eaca2e83e26a12882f5d8a63e450349a1cdf82e
signature:
  fun buildGuidePrompt(location: Map<String, String>, pois: List<Map<String, Any>>, interests: List<Interest>, userQuery: String = "", lang: String = "es"): List<ChatMessage>

description:
  Construye un prompt contextual para el LLM como guía local combinando ubicación actual, POIs cercanos rankeados e intereses del usuario. Puerto Kotlin de build_guide_prompt_py_core. Retorna exactamente 2 mensajes: system y user en formato OpenAI/Ollama.

example:
  ```kotlin
  val location = mapOf(
      "display_name" to "Calle Mayor, 1, Madrid, España",
      "neighbourhood" to "Centro",
      "city" to "Madrid"
  )

  val pois = listOf(
      mapOf(
          "name" to "Museo del Prado",
          "category" to "museum",
          "score" to 0.95,
          "matched_interests" to listOf("arte", "historia")
      )
  )

  val interests = listOf(
      Interest(name = "arte", keywords = listOf("pintura", "escultura"), weight = 1.0),
      Interest(name = "historia", keywords = listOf("medieval", "siglo XIX"), weight = 0.8)
  )

  val messages = buildGuidePrompt(
      location = location,
      pois = pois,
      interests = interests,
      userQuery = "¿Cuándo abre el Prado?",
      lang = "es"
  )

  // messages[0].role == "system"
  // messages[1].role == "user"
  // messages[1].content contiene "Museo del Prado (museum) — relevante para: arte, historia"
  ```

notes:
  Puerto directo de `build_guide_prompt_py_core` al idioma Kotlin.

  Define dos data classes auxiliares en el mismo archivo:
  - `Interest(name, keywords, weight)` — interés del usuario
  - `ChatMessage(role, content)` — mensaje en formato OpenAI/Ollama

  Comportamiento idéntico al original Python:
  - `city` se extrae con fallback: `city` → `town` → `village` → "tu zona"
  - `neighbourhood` incluye fallback a `suburb`
  - Máximo 5 POIs en el user message
  - Omite línea de barrio si neighbourhood y ciudad están vacíos
  - Omite sección POIs si la lista está vacía
  - Omite `display_name` si está vacío

  Función pura: sin I/O, sin estado mutable, determinista.

code:
  package core

  /**
   * Representa un interes del usuario con nombre, keywords y peso.
   */
  data class Interest(
      val name: String,
      val keywords: List<String> = emptyList(),
      val weight: Double = 1.0
  )

  /**
   * Mensaje en formato OpenAI/Ollama para conversaciones con LLMs.
   */
  data class ChatMessage(
      val role: String,  // "system" | "user" | "assistant"
      val content: String
  )

  /**
   * buildGuidePrompt construye un prompt contextual para el LLM como guía local.
   *
   * Combina la ubicación actual del usuario, POIs cercanos rankeados por relevancia
   * e intereses del usuario para generar mensajes en formato OpenAI/Ollama.
   *
   * @param location Mapa con info de ubicación (nominatim reverse geocode).
   *                 Campos esperados: display_name, street, neighbourhood, city, country, etc.
   * @param pois Lista de POIs rankeados. Cada mapa contiene: name, category, score,
   *             matched_interests (List<String>), lat, lon, tags. Ya filtrados y ordenados.
   * @param interests Lista de intereses del usuario.
   * @param userQuery Pregunta opcional del usuario. Si está vacía, se usa un default.
   * @param lang Idioma para la respuesta del LLM (ej: "es", "en", "fr").
   * @return Lista de dos ChatMessage: [system, user].
   */
  fun buildGuidePrompt(
      location: Map<String, String>,
      pois: List<Map<String, Any>>,
      interests: List<Interest>,
      userQuery: String = "",
      lang: String = "es"
  ): List<ChatMessage> {
      // Extraer nombre de intereses para el system prompt
      val interestsStr = if (interests.isNotEmpty()) {
          interests.filter { it.name.isNotEmpty() }.joinToString(", ") { it.name }
              .ifEmpty { "lugares de interés general" }
      } else {
          "lugares de interés general"
      }

      // Extraer ciudad para el system prompt
      val city = location["city"]?.takeIf { it.isNotEmpty() }
          ?: location["town"]?.takeIf { it.isNotEmpty() }
          ?: location["village"]?.takeIf { it.isNotEmpty() }
          ?: "tu zona"

      // Construir system message
      val systemContent = buildString {
          append("Eres un guía local experto y amigable. El usuario está caminando por $city ")
          append("y le interesan: $interestsStr.\n")
          append("Tu objetivo es informar sobre lo que hay cerca, dar contexto histórico/cultural ")
          append("relevante a sus intereses, y sugerir qué visitar.\n")
          append("Responde en $lang. Sé conciso (máximo 3-4 frases) y conversacional, ")
          append("como si caminaras junto al usuario.\n")
          append("Si no hay POIs relevantes, comenta sobre el barrio o zona.")
      }

      // Construir user message
      val userParts = mutableListOf<String>()

      // Ubicación
      val displayName = location["display_name"] ?: ""
      if (displayName.isNotEmpty()) {
          userParts.add("📍 Estoy en: $displayName")
      }

      val neighbourhood = location["neighbourhood"]?.takeIf { it.isNotEmpty() }
          ?: location["suburb"]?.takeIf { it.isNotEmpty() }
          ?: ""
      val cityLabel = location["city"]?.takeIf { it.isNotEmpty() }
          ?: location["town"]?.takeIf { it.isNotEmpty() }
          ?: location["village"]?.takeIf { it.isNotEmpty() }
          ?: ""
      val locationLineParts = listOf(neighbourhood, cityLabel).filter { it.isNotEmpty() }
      if (locationLineParts.isNotEmpty()) {
          userParts.add("Barrio: ${locationLineParts.joinToString(", ")}")
      }

      // POIs (máximo 5)
      val topPois = pois.take(5)
      if (topPois.isNotEmpty()) {
          userParts.add("")
          userParts.add("Lugares cercanos que me pueden interesar:")
          for (poi in topPois) {
              val name = poi["name"] as? String ?: "Sin nombre"
              val category = poi["category"] as? String ?: ""
              @Suppress("UNCHECKED_CAST")
              val matched = poi["matched_interests"] as? List<String> ?: emptyList()
              val matchedStr = matched.joinToString(", ")

              val poiLine = buildString {
                  append("- $name")
                  if (category.isNotEmpty()) append(" ($category)")
                  if (matchedStr.isNotEmpty()) append(" — relevante para: $matchedStr")
              }
              userParts.add(poiLine)
          }
      }

      // Query del usuario
      userParts.add("")
      val trimmedQuery = userQuery.trim()
      userParts.add(if (trimmedQuery.isNotEmpty()) trimmedQuery else "¿Qué me puedes contar de esta zona?")

      val userContent = userParts.joinToString("\n")

      return listOf(
          ChatMessage(role = "system", content = systemContent),
          ChatMessage(role = "user", content = userContent)
      )
  }

params_schema:
  {"params":[{"name":"location","desc":"Mapa con info de ubicación de nominatim reverse geocode. Campos: display_name, neighbourhood, suburb, city, town, village, country, etc."},{"name":"pois","desc":"Lista de POIs rankeados (máx 5 se usan). Cada mapa: name, category, score, matched_interests (List\u003cString\u003e), lat, lon."},{"name":"interests","desc":"Lista de objetos Interest con name, keywords y weight. Determina el foco del sistema prompt."},{"name":"userQuery","desc":"Pregunta libre del usuario. Si vacía, se usa '¿Qué me puedes contar de esta zona?' como default."},{"name":"lang","desc":"Código de idioma para la respuesta del LLM (ej: 'es', 'en', 'fr')."}],"output":"Lista de exactamente 2 ChatMessage: [system, user]. System describe el rol de guía con ciudad e intereses. User incluye ubicación GPS, barrio, POIs relevantes y la query del usuario."}


id: match_pois_to_interests_kt_core
name: match_pois_to_interests
kind: function
lang: kt
domain: core
version: 1.0.0
purity: pure
tags: ["poi","scoring","ranking","geolocation","interests","voice-guide","osm"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: kotlin/functions/core/match_pois_to_interests.kt
created_at: 2026-04-06T16:55:12Z
updated_at: 2026-04-06T16:55:12Z
props: []
emits: []
variant: []
content_hash: 699311b65d87e8c49894dcf3061430eb8ef8e4af74021a9ca3bbf27744741e9b
signature:
  fun matchPoisToInterests(pois: List<Map<String, Any>>, interests: List<Interest>, maxResults: Int = 10): List<ScoredPOI>

description:
  Filtra y rankea una lista de POIs segun un perfil de intereses del usuario. Calcula un score por categoria (0.5), nombre (0.3) y tags (0.2), multiplicado por el weight del interes. Retorna los top maxResults POIs con score > 0, enriquecidos con score y matchedInterests.

example:
  ```kotlin
  val interests = listOf(
      Interest(name = "gastronomia", keywords = listOf("restaurant", "cafe", "bar"), weight = 1.5),
      Interest(name = "cultura", keywords = listOf("museum", "historic", "monument"), weight = 1.0)
  )

  val pois = listOf(
      mapOf(
          "id" to 1L,
          "lat" to 40.416775,
          "lon" to -3.703790,
          "name" to "Museo del Prado",
          "category" to "museum",
          "tags" to mapOf("tourism" to "museum", "name" to "Museo Nacional del Prado")
      ),
      mapOf(
          "id" to 2L,
          "lat" to 40.415,
          "lon" to -3.700,
          "name" to "Cafe Central",
          "category" to "cafe",
          "tags" to mapOf("amenity" to "cafe")
      )
  )

  val results = matchPoisToInterests(pois, interests, maxResults = 5)
  // results[0].name == "Museo del Prado", score == 1.0  (categoria 0.5 + tags 0.2 = 0.7 × 1.0, name "museo" no matchea "museum" como substring de "museo del prado"... wait: "museum" no esta en "museo del prado")
  // results[0].matchedInterests == ["cultura"]
  // results[1].name == "Cafe Central", score == 1.2  (categoria 0.5 × 1.5)
  ```

notes:
  - Puerto exacto de `match_pois_to_interests_py_core` (Python) al stack Kotlin.
  - Los POIs se reciben como `List<Map<String, Any>>` porque provienen de JSON parseado (sin schema fijo).
  - El id se casteá con fallback `Int -> Long` para compatibilidad con parsers JSON que usan Int por defecto.
  - El score se redondea a 6 decimales para paridad con `round(score, 6)` de Python.
  - Funcion pura: no hace I/O, no muta los inputs, determinista.
  - Los tipos `Interest` y `ScoredPOI` se definen en el mismo archivo para mantener el paquete `core` autocontenido.

documentation:
  ## Algoritmo de scoring

  Para cada POI, por cada interés:

  1. **Category match** (+0.5): `poi.category.lowercase() == keyword` (comparación exacta)
  2. **Name match** (+0.3): `keyword in poi.name.lowercase()` (substring, case-insensitive)
  3. **Tags match** (+0.2): `keyword in tagValues.lowercase()` donde `tagValues = tags.values.joinToString(" ")`

  El subtotal del interés se multiplica por `interest.weight` y se acumula en `totalScore`.

  Solo se incluye el interés en `matchedInterests` si su `interestScore > 0`.

code:
  package core

  /**
   * Represents a user interest with associated keywords and a relevance weight.
   *
   * @param name Human-readable label for the interest (e.g. "gastronomia").
   * @param keywords Terms used to match against POI category, name and tags.
   * @param weight Multiplier applied to the raw score for this interest. Defaults to 1.0.
   */
  data class Interest(
      val name: String,
      val keywords: List<String>,
      val weight: Double = 1.0
  )

  /**
   * A POI enriched with a relevance score and the list of interests it matched.
   *
   * @param id Unique identifier from the source data.
   * @param lat Latitude in decimal degrees.
   * @param lon Longitude in decimal degrees.
   * @param name Human-readable name of the place.
   * @param category OSM-style category string (e.g. "restaurant", "museum").
   * @param tags Arbitrary key-value metadata from the source (OSM tags, etc.).
   * @param score Aggregated relevance score (sum of per-interest scores × weights).
   * @param matchedInterests Names of the interests that contributed to the score.
   */
  data class ScoredPOI(
      val id: Long,
      val lat: Double,
      val lon: Double,
      val name: String,
      val category: String,
      val tags: Map<String, String>,
      val score: Double,
      val matchedInterests: List<String>
  )

  /**
   * Filters and ranks a list of POIs according to a user interest profile.
   *
   * Scoring per interest (weights applied to each sub-score):
   *   - Category match : +0.5 if `poi["category"]` equals any keyword (exact, case-insensitive)
   *   - Name match     : +0.3 if any keyword is a substring of `poi["name"]` (case-insensitive)
   *   - Tags match     : +0.2 if any keyword appears in any tag value (case-insensitive)
   *
   * The per-interest score is multiplied by [Interest.weight] before being added to the total.
   * Multiple interests accumulate. Only POIs with total score > 0 are returned.
   *
   * @param pois Raw POI maps from parsed JSON. Expected keys: "id" (Long), "lat" (Double),
   *             "lon" (Double), "name" (String), "category" (String), "tags" (Map<String,String>).
   * @param interests User interest profile to match against.
   * @param maxResults Maximum number of results to return. Defaults to 10.
   * @return Top [maxResults] POIs with score > 0, sorted by score descending.
   *         Empty list if no POI matches any interest.
   */
  fun matchPoisToInterests(
      pois: List<Map<String, Any>>,
      interests: List<Interest>,
      maxResults: Int = 10
  ): List<ScoredPOI> {
      val scored = mutableListOf<ScoredPOI>()

      for (poi in pois) {
          val category = (poi["category"] as? String ?: "").lowercase()
          val name = (poi["name"] as? String ?: "").lowercase()

          @Suppress("UNCHECKED_CAST")
          val tags = (poi["tags"] as? Map<String, String>) ?: emptyMap()
          val tagValues = tags.values.joinToString(" ").lowercase()

          var totalScore = 0.0
          val matched = mutableListOf<String>()

          for (interest in interests) {
              val keywords = interest.keywords.map { it.lowercase() }
              var interestScore = 0.0

              // Category match: 0.5 if poi category equals any keyword (exact)
              if (keywords.any { it == category }) {
                  interestScore += 0.5
              }

              // Name match: 0.3 if any keyword is a substring of poi name
              if (keywords.any { it in name }) {
                  interestScore += 0.3
              }

              // Tags match: 0.2 if any keyword appears in any tag value
              if (keywords.any { it in tagValues }) {
                  interestScore += 0.2
              }

              if (interestScore > 0.0) {
                  totalScore += interestScore * interest.weight
                  matched.add(interest.name)
              }
          }

          if (totalScore > 0.0) {
              scored.add(
                  ScoredPOI(
                      id = (poi["id"] as? Long) ?: (poi["id"] as? Int)?.toLong() ?: 0L,
                      lat = (poi["lat"] as? Double) ?: 0.0,
                      lon = (poi["lon"] as? Double) ?: 0.0,
                      name = poi["name"] as? String ?: "",
                      category = poi["category"] as? String ?: "",
                      tags = tags,
                      score = Math.round(totalScore * 1_000_000.0) / 1_000_000.0,
                      matchedInterests = matched.toList()
                  )
              )
          }
      }

      return scored
          .sortedByDescending { it.score }
          .take(maxResults)
  }

params_schema:
  {"params":[{"name":"pois","desc":"Lista de POIs parseados de JSON. Cada mapa contiene: id (Long), lat (Double), lon (Double), name (String), category (String), tags (Map\u003cString,String\u003e)."},{"name":"interests","desc":"Perfil de intereses del usuario. Cada Interest tiene name, keywords (lista de terminos de busqueda) y weight (multiplicador de score, default 1.0)."},{"name":"maxResults","desc":"Numero maximo de POIs a retornar, ordenados por score descendente. Default 10."}],"output":"Lista de ScoredPOI con score \u003e 0, ordenados por score descendente, hasta maxResults elementos. Lista vacia si ningun POI coincide con algun interes."}


id: nominatim_reverse_geocode_kt_infra
name: nominatim_reverse_geocode
kind: function
lang: kt
domain: infra
version: 1.0.0
purity: impure
signature: fun nominatimReverseGeocode(lat: Double, lon: Double, lang: String = "es"): GeocodedLocation
tags: ["geocoding","nominatim","openstreetmap","location","address","infra","android","kotlin"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["org.json.JSONObject","java.net.HttpURLConnection","java.net.URL"]
tests: []
file_path: kotlin/functions/infra/nominatim_reverse_geocode.kt
created_at: 2026-04-06T16:54:48Z
updated_at: 2026-04-06T16:54:48Z
props: []
emits: []
variant: []
content_hash: fe6287d2206952a5ec5f9e67691cf9a5f51ec1ec510c4e96739a24797b1c2352
description:
  Reverse geocoding usando Nominatim (OpenStreetMap). Convierte coordenadas lat/lon a una dirección estructurada (calle, ciudad, país, etc.) para Android/Kotlin sin dependencias externas.

example:
  ```kotlin
  val loc = nominatimReverseGeocode(40.4168, -3.7038)
  println(loc.city)        // "Madrid"
  println(loc.street)      // "Calle Mayor"
  println(loc.country)     // "España"
  println(loc.displayName) // cadena completa de Nominatim
  ```

notes:
  Función paralela a `nominatim_reverse_geocode_py_infra` pero adaptada para Android/Kotlin.
  No llamar desde el hilo principal de Android — usar `Dispatchers.IO` o un `ExecutorService`.
  El campo `zoom=18` maximiza el detalle de la dirección (nivel de número de portal).

documentation:
  ## Comportamiento

  - Realiza GET a `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=...&lon=...&accept-language=...&zoom=18`.
  - Cabecera `User-Agent: fn_registry/1.0` obligatoria por política de uso de Nominatim.
  - Timeout de 5 segundos tanto para conexión como para lectura.
  - Usa solo `java.net.HttpURLConnection` y `org.json.JSONObject` — disponibles en el Android SDK sin dependencias externas.
  - Resolución de `city`: prueba `address.city` → `address.town` → `address.village` → `""`.
  - Resolución de `neighbourhood`: prueba `address.neighbourhood` → `address.suburb` → `""`.
  - Lanza `RuntimeException` si el HTTP status no es 200 o si falla la lectura de la respuesta.

code:
  package infra

  import org.json.JSONObject
  import java.net.HttpURLConnection
  import java.net.URL

  data class GeocodedLocation(
      val displayName: String,
      val street: String,
      val houseNumber: String,
      val neighbourhood: String,
      val city: String,
      val state: String,
      val country: String,
      val postcode: String,
      val lat: Double,
      val lon: Double,
      val osmType: String,
      val osmId: Long
  )

  /**
   * nominatimReverseGeocode — Convierte coordenadas lat/lon a una dirección estructurada
   * usando la API pública de Nominatim (OpenStreetMap).
   *
   * @param lat  Latitud en grados decimales.
   * @param lon  Longitud en grados decimales.
   * @param lang Código de idioma ISO 639-1 para la respuesta. Por defecto "es".
   * @return     GeocodedLocation con los campos normalizados de la dirección.
   * @throws     RuntimeException si la petición HTTP falla o el servidor retorna error.
   */
  fun nominatimReverseGeocode(lat: Double, lon: Double, lang: String = "es"): GeocodedLocation {
      val url = URL(
          "https://nominatim.openstreetmap.org/reverse" +
              "?format=jsonv2" +
              "&lat=$lat" +
              "&lon=$lon" +
              "&accept-language=$lang" +
              "&zoom=18"
      )

      val conn = url.openConnection() as HttpURLConnection
      conn.setRequestProperty("User-Agent", "fn_registry/1.0")
      conn.connectTimeout = 5_000
      conn.readTimeout = 5_000
      conn.requestMethod = "GET"

      val responseCode = conn.responseCode
      if (responseCode != HttpURLConnection.HTTP_OK) {
          conn.disconnect()
          throw RuntimeException(
              "nominatimReverseGeocode: HTTP $responseCode para lat=$lat, lon=$lon"
          )
      }

      val body = try {
          conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
      } catch (e: Exception) {
          conn.disconnect()
          throw RuntimeException(
              "nominatimReverseGeocode: error leyendo respuesta — ${e.message}", e
          )
      } finally {
          conn.disconnect()
      }

      val data = JSONObject(body)
      val address = data.optJSONObject("address") ?: JSONObject()

      val city = when {
          address.has("city") && address.getString("city").isNotEmpty() ->
              address.getString("city")
          address.has("town") && address.getString("town").isNotEmpty() ->
              address.getString("town")
          address.has("village") && address.getString("village").isNotEmpty() ->
              address.getString("village")
          else -> ""
      }

      val neighbourhood = when {
          address.has("neighbourhood") && address.getString("neighbourhood").isNotEmpty() ->
              address.getString("neighbourhood")
          address.has("suburb") && address.getString("suburb").isNotEmpty() ->
              address.getString("suburb")
          else -> ""
      }

      return GeocodedLocation(
          displayName = data.optString("display_name", ""),
          street      = address.optString("road", ""),
          houseNumber = address.optString("house_number", ""),
          neighbourhood = neighbourhood,
          city        = city,
          state       = address.optString("state", ""),
          country     = address.optString("country", ""),
          postcode    = address.optString("postcode", ""),
          lat         = data.optString("lat", lat.toString()).toDoubleOrNull() ?: lat,
          lon         = data.optString("lon", lon.toString()).toDoubleOrNull() ?: lon,
          osmType     = data.optString("osm_type", ""),
          osmId       = data.optLong("osm_id", 0L)
      )
  }

params_schema:
  {"params":[{"name":"lat","desc":"Latitud en grados decimales (-90.0 a 90.0)."},{"name":"lon","desc":"Longitud en grados decimales (-180.0 a 180.0)."},{"name":"lang","desc":"Código de idioma ISO 639-1 para la respuesta de Nominatim. Por defecto \"es\" (español)."}],"output":"GeocodedLocation con los campos normalizados: displayName, street, houseNumber, neighbourhood, city, state, country, postcode, lat, lon, osmType, osmId."}


id: ollama_chat_kt_infra
name: ollama_chat
kind: function
lang: kt
domain: infra
version: 1.0.0
purity: impure
tags: ["ollama","llm","chat","inference","http","android","kotlin"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["org.json.JSONObject","org.json.JSONArray","java.net.HttpURLConnection","java.net.URL"]
tests: []
file_path: kotlin/functions/infra/ollama_chat.kt
created_at: 2026-04-06T16:55:18Z
updated_at: 2026-04-06T16:55:18Z
props: []
emits: []
variant: []
content_hash: 1b6dca9db9ab40f3e95b6c417a9a3dba13872627edcb4caf6103d55017ce984e
signature:
  fun ollamaChat(messages: List<Map<String, String>>, model: String = "llama3.1:8b", baseUrl: String = "http://localhost:11434", temperature: Double = 0.7, maxTokens: Int = 1024): OllamaChatResponse

description:
  Envía una solicitud de chat completion a un servidor Ollama local. Retorna el contenido generado junto a métricas de duración y tokens evaluados.

example:
  ```kotlin
  val messages = listOf(
      mapOf("role" to "system", "content" to "Eres un asistente útil."),
      mapOf("role" to "user", "content" to "¿Cuál es la capital de Francia?")
  )

  val response = ollamaChat(
      messages = messages,
      model = "llama3.1:8b",
      temperature = 0.3
  )

  println(response.content)          // "La capital de Francia es París."
  println(response.totalDurationMs)  // 1234
  println(response.evalCount)        // 42
  ```

notes:
  Usa `java.net.HttpURLConnection` y `org.json.JSONObject` del Android SDK — sin dependencias externas adicionales. Timeout de 60 segundos tanto en conexión como en lectura.

  La respuesta de Ollama incluye `total_duration` en nanosegundos; se convierte a milisegundos dividiendo entre 1_000_000.

  Si Ollama no está corriendo lanza `RuntimeException("Ollama no está corriendo en $baseUrl")`. Si el servidor responde con un código HTTP distinto de 200, lanza `RuntimeException` con el código y el cuerpo del error.

  El campo `stream` se fija a `false` para recibir la respuesta completa en una sola llamada.
  ```
  ---

code:
  package infra

  import org.json.JSONArray
  import org.json.JSONObject
  import java.io.ConnectException
  import java.net.HttpURLConnection
  import java.net.URL

  data class OllamaChatResponse(
      val content: String,
      val model: String,
      val totalDurationMs: Long,
      val evalCount: Int
  )

  /**
   * Sends a chat completion request to a local Ollama instance.
   *
   * POSTs to `$baseUrl/api/chat` with the given messages and returns the
   * generated content together with timing and token-count metrics.
   *
   * @param messages  List of role/content maps, e.g. [{"role":"user","content":"hi"}].
   * @param model     Ollama model tag to use.
   * @param baseUrl   Base URL of the Ollama server.
   * @param temperature  Sampling temperature (0.0 – 1.0).
   * @param maxTokens    Maximum number of tokens to generate.
   * @return OllamaChatResponse with content, model name, duration and eval count.
   * @throws RuntimeException if Ollama is not reachable or the server returns an error.
   */
  fun ollamaChat(
      messages: List<Map<String, String>>,
      model: String = "llama3.1:8b",
      baseUrl: String = "http://localhost:11434",
      temperature: Double = 0.7,
      maxTokens: Int = 1024
  ): OllamaChatResponse {
      val messagesArray = JSONArray().apply {
          for (msg in messages) {
              put(JSONObject(msg as Map<*, *>))
          }
      }

      val options = JSONObject().apply {
          put("temperature", temperature)
          put("num_predict", maxTokens)
      }

      val body = JSONObject().apply {
          put("model", model)
          put("messages", messagesArray)
          put("stream", false)
          put("options", options)
      }.toString().toByteArray(Charsets.UTF_8)

      val url = URL("$baseUrl/api/chat")
      val conn: HttpURLConnection

      try {
          conn = url.openConnection() as HttpURLConnection
      } catch (e: ConnectException) {
          throw RuntimeException("Ollama no está corriendo en $baseUrl", e)
      }

      try {
          conn.requestMethod = "POST"
          conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
          conn.setRequestProperty("Accept", "application/json")
          conn.connectTimeout = 60_000
          conn.readTimeout = 60_000
          conn.doOutput = true

          try {
              conn.outputStream.use { it.write(body) }
          } catch (e: ConnectException) {
              throw RuntimeException("Ollama no está corriendo en $baseUrl", e)
          }

          val statusCode = conn.responseCode
          if (statusCode != HttpURLConnection.HTTP_OK) {
              val errorBody = conn.errorStream?.bufferedReader()?.readText() ?: ""
              throw RuntimeException("Ollama devolvió HTTP $statusCode: $errorBody")
          }

          val responseText = conn.inputStream.bufferedReader().readText()
          val json = JSONObject(responseText)

          val content = json.getJSONObject("message").getString("content")
          val returnedModel = json.optString("model", model)
          val totalDurationNs = json.optLong("total_duration", 0L)
          val evalCount = json.optInt("eval_count", 0)

          return OllamaChatResponse(
              content = content,
              model = returnedModel,
              totalDurationMs = totalDurationNs / 1_000_000L,
              evalCount = evalCount
          )
      } finally {
          conn.disconnect()
      }
  }

params_schema:
  {"params":[{"name":"messages","desc":"Lista de mapas role/content en formato OpenAI/Ollama. Ej: [{\"role\":\"user\",\"content\":\"Hola\"}]."},{"name":"model","desc":"Tag del modelo Ollama a usar. Por defecto 'llama3.1:8b'."},{"name":"baseUrl","desc":"URL base del servidor Ollama. Por defecto 'http://localhost:11434'."},{"name":"temperature","desc":"Temperatura de muestreo entre 0.0 (determinista) y 1.0 (creativo). Por defecto 0.7."},{"name":"maxTokens","desc":"Número máximo de tokens a generar. Por defecto 1024."}],"output":"OllamaChatResponse con content (texto generado), model (nombre del modelo), totalDurationMs (duración total en milisegundos) y evalCount (tokens evaluados)."}


id: overpass_nearby_pois_kt_infra
name: overpass_nearby_pois
kind: function
lang: kt
domain: infra
version: 1.0.0
purity: impure
tags: ["overpass","openstreetmap","osm","poi","geospatial","location","android","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
tests: []
file_path: kotlin/functions/infra/overpass_nearby_pois.kt
created_at: 2026-04-06T16:54:56Z
updated_at: 2026-04-06T16:54:56Z
props: []
emits: []
variant: []
content_hash: 8e0ff08cec35c8f8a63aa235de86ec1eb111272ee275f67b30861341edbe263f
signature:
  fun overpassNearbyPois(lat: Double, lon: Double, radiusM: Int = 500, categories: List<String>? = null): List<POI>

description:
  Consulta la Overpass API (OpenStreetMap) para obtener POIs cercanos a una coordenada. Soporta 16 categorias mapeadas a tags OSM (amenity, tourism, historic, leisure, shop). Sin dependencias externas: solo Android SDK (HttpURLConnection + org.json).

imports:
  ["org.json.JSONObject","org.json.JSONArray","java.net.HttpURLConnection","java.net.URL","java.net.URLEncoder"]

example:
  ```kotlin
  // Restaurantes y museos en un radio de 300 metros
  val pois = overpassNearbyPois(
      lat = 40.4168,
      lon = -3.7038,
      radiusM = 300,
      categories = listOf("restaurant", "museum")
  )
  pois.forEach { poi ->
      println("${poi.name} (${poi.category}) — lat=${poi.lat}, lon=${poi.lon}")
  }

  // Todos los POIs en 500 metros (null = todas las categorias)
  val all = overpassNearbyPois(lat = 48.8566, lon = 2.3522)
  println("Paris: ${all.size} POIs encontrados")
  ```

notes:
  - Usa `java.net.HttpURLConnection` con POST y `data=<query_url_encoded>`.
  - Timeouts: 10 segundos de conexion y lectura. La query QL tambien declara `[timeout:10]`.
  - El tipo `POI` se define en el mismo archivo como `data class`.
  - El mapa `tags` contiene todos los tags OSM del nodo, no solo el de la categoria.
  - Si un nodo no tiene tag `name`, el fallback es la categoria y luego "Unknown".
  - Las categorias wildcard (historic, tourism, shop) usan `node["key"](around:...)` sin valor.
  - Lanza `RuntimeException` en caso de categorias invalidas, error HTTP, o JSON malformado.
  - Compatible con Android SDK >= 19 (API level 19 tiene org.json integrado).
  ---

documentation:
  ## Categorias soportadas

  | Categoria    | Tag OSM              |
  |--------------|----------------------|
  | restaurant   | amenity=restaurant   |
  | cafe         | amenity=cafe         |
  | bar          | amenity=bar          |
  | museum       | tourism=museum       |
  | monument     | historic=monument    |
  | park         | leisure=park         |
  | library      | amenity=library      |
  | theatre      | amenity=theatre      |
  | cinema       | amenity=cinema       |
  | gallery      | tourism=gallery      |
  | historic     | historic (wildcard)  |
  | tourism      | tourism (wildcard)   |
  | shop         | shop (wildcard)      |
  | hotel        | tourism=hotel        |
  | pharmacy     | amenity=pharmacy     |
  | hospital     | amenity=hospital     |

  ## Query Overpass QL generada

  ```
  [timeout:10][out:json];
  (
    node["amenity"="restaurant"](around:500,40.4168,-3.7038);
    node["tourism"="museum"](around:500,40.4168,-3.7038);
  );
  out body;
  ```

code:
  package infra

  import org.json.JSONObject
  import org.json.JSONArray
  import java.net.HttpURLConnection
  import java.net.URL
  import java.net.URLEncoder

  data class POI(
      val id: Long,
      val lat: Double,
      val lon: Double,
      val name: String,
      val category: String,
      val tags: Map<String, String>
  )

  private val CATEGORY_TAG_MAP: Map<String, String> = mapOf(
      "restaurant" to "amenity=restaurant",
      "cafe"       to "amenity=cafe",
      "bar"        to "amenity=bar",
      "museum"     to "tourism=museum",
      "monument"   to "historic=monument",
      "park"       to "leisure=park",
      "library"    to "amenity=library",
      "theatre"    to "amenity=theatre",
      "cinema"     to "amenity=cinema",
      "gallery"    to "tourism=gallery",
      "historic"   to "historic",
      "tourism"    to "tourism",
      "shop"       to "shop",
      "hotel"      to "tourism=hotel",
      "pharmacy"   to "amenity=pharmacy",
      "hospital"   to "amenity=hospital"
  )

  /** Builds the Overpass QL node clause for a single category tag expression. */
  private fun nodeClause(tagExpr: String, radiusM: Int, lat: Double, lon: Double): String {
      return if (tagExpr.contains('=')) {
          val (key, value) = tagExpr.split('=', limit = 2)
          """node["$key"="$value"](around:$radiusM,$lat,$lon);"""
      } else {
          // Wildcard: match any value for the key
          """node["$tagExpr"](around:$radiusM,$lat,$lon);"""
      }
  }

  /**
   * OverpassNearbyPois queries the Overpass API (OpenStreetMap) to retrieve POIs
   * near the given coordinates within the specified radius.
   *
   * Uses only the Android SDK (java.net.HttpURLConnection + org.json) — no external
   * dependencies. Throws RuntimeException on HTTP or parse errors.
   *
   * @param lat       Latitude of the center point (WGS84)
   * @param lon       Longitude of the center point (WGS84)
   * @param radiusM   Search radius in metres (default: 500)
   * @param categories Subset of supported categories to filter by, or null for all
   * @return List of POIs found, may be empty
   */
  fun overpassNearbyPois(
      lat: Double,
      lon: Double,
      radiusM: Int = 500,
      categories: List<String>? = null
  ): List<POI> {
      val resolved: Map<String, String> = if (categories == null) {
          CATEGORY_TAG_MAP
      } else {
          CATEGORY_TAG_MAP.filterKeys { it in categories }
      }

      if (resolved.isEmpty()) {
          throw RuntimeException("No valid categories resolved from: $categories")
      }

      val nodeClauses = resolved.values.joinToString("\n  ") { tagExpr ->
          nodeClause(tagExpr, radiusM, lat, lon)
      }
      val query = "[timeout:10][out:json];\n(\n  $nodeClauses\n);\nout body;"

      val endpoint = "https://overpass-api.de/api/interpreter"
      val encodedData = "data=" + URLEncoder.encode(query, "UTF-8")

      val conn = (URL(endpoint).openConnection() as HttpURLConnection).apply {
          requestMethod = "POST"
          connectTimeout = 10_000
          readTimeout = 10_000
          doOutput = true
          setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
          setRequestProperty("Accept", "application/json")
      }

      try {
          conn.outputStream.use { it.write(encodedData.toByteArray(Charsets.UTF_8)) }

          val code = conn.responseCode
          if (code != 200) {
              val err = conn.errorStream?.bufferedReader()?.readText() ?: ""
              throw RuntimeException("Overpass API returned HTTP $code: $err")
          }

          val body = conn.inputStream.bufferedReader().readText()
          val root = JSONObject(body)
          val elements: JSONArray = root.optJSONArray("elements")
              ?: return emptyList()

          val tagExprByCategory: Map<String, String> = resolved.entries
              .associate { (cat, tagExpr) -> tagExpr to cat }

          val pois = mutableListOf<POI>()
          for (i in 0 until elements.length()) {
              val el = elements.getJSONObject(i)
              if (el.optString("type") != "node") continue

              val id  = el.getLong("id")
              val eLat = el.getDouble("lat")
              val eLon = el.getDouble("lon")
              val tagsObj: JSONObject = el.optJSONObject("tags") ?: JSONObject()

              // Build tags map
              val tagsMap = mutableMapOf<String, String>()
              for (key in tagsObj.keys()) {
                  tagsMap[key] = tagsObj.getString(key)
              }

              // Determine category from the first matching tag expression
              val category = tagExprByCategory.entries.firstOrNull { (tagExpr, _) ->
                  if (tagExpr.contains('=')) {
                      val (k, v) = tagExpr.split('=', limit = 2)
                      tagsMap[k] == v
                  } else {
                      tagsMap.containsKey(tagExpr)
                  }
              }?.value ?: "unknown"

              val name = tagsMap["name"] ?: category.takeIf { it != "unknown" } ?: "Unknown"

              pois.add(POI(id = id, lat = eLat, lon = eLon, name = name, category = category, tags = tagsMap))
          }

          return pois
      } finally {
          conn.disconnect()
      }
  }

params_schema:
  {"params":[{"name":"lat","desc":"Latitud del punto central en WGS84 (ej: 40.4168 para Madrid)"},{"name":"lon","desc":"Longitud del punto central en WGS84 (ej: -3.7038 para Madrid)"},{"name":"radiusM","desc":"Radio de busqueda en metros. Default 500. Valores grandes aumentan el tiempo de respuesta."},{"name":"categories","desc":"Lista de categorias a filtrar: restaurant, cafe, bar, museum, monument, park, library, theatre, cinema, gallery, historic, tourism, shop, hotel, pharmacy, hospital. Null devuelve todas."}],"output":"Lista de POI con id OSM, coordenadas (lat/lon), nombre, categoria y mapa completo de tags OSM. Lanza RuntimeException si la API devuelve error HTTP o la respuesta no es JSON valido."}


id: win_firewall_add_rule_ps_infra
name: win_firewall_add_rule
kind: function
lang: ps
domain: infra
version: 1.0.0
purity: impure
signature: win_firewall_add_rule -Name <string> -Port <int> [-Protocol <string>]
tags: ["firewall","windows","netsh","network","infra","security","port","wsl2"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: powershell/functions/infra/win_firewall_add_rule.ps1
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 14ac1a8b15c658eadf232e5e4833e6d7faaf7d73ada3ec12ff08dcbaf374d3de
description:
  Añade una regla de entrada al firewall de Windows para permitir tráfico en un puerto TCP/UDP. Si ya existe una regla con el mismo nombre, la elimina y la recrea. Requiere privilegios de Administrador.

notes:
  - Requiere ejecutarse como **Administrador** en Windows. Si se llama desde WSL2, usar `powershell.exe` (que corre en Windows) o `pwsh.exe`.
  - Si ya existe una regla con el mismo nombre, la elimina primero y la recrea (idempotente respecto al nombre).
  - La regla creada es `dir=in action=allow` — solo aplica para tráfico entrante.
  - Caso de uso principal: permitir que WSL2 alcance Chrome CDP en el host Windows cuando Chrome escucha en `127.0.0.1:9222`.
  - Retorna exit code 0 si tuvo éxito, 1 si hubo error (falta de privilegios, puerto inválido, fallo de netsh).

documentation:
  ## Uso

  ```powershell
  # Desde PowerShell (como Administrador)
  .\win_firewall_add_rule.ps1 -Name "CDP-9222" -Port 9222

  # Con protocolo explícito
  .\win_firewall_add_rule.ps1 -Name "MyApp-UDP-5000" -Port 5000 -Protocol UDP
  ```

  ```bash
  # Desde WSL2
  powershell.exe -ExecutionPolicy Bypass -File win_firewall_add_rule.ps1 -Name "CDP-9222" -Port 9222
  ```

  ## Parametros

  | Parametro  | Tipo   | Obligatorio | Default | Descripcion                          |
  |------------|--------|-------------|---------|--------------------------------------|
  | `-Name`    | string | si          | —       | Nombre de la regla en el firewall    |
  | `-Port`    | int    | si          | —       | Puerto local (1-65535)               |
  | `-Protocol`| string | no          | TCP     | Protocolo: TCP o UDP                 |

code:
  # win_firewall_add_rule.ps1 - Adds a Windows Firewall inbound rule for a TCP/UDP port.
  # Requires: Administrator privileges
  # Usage: powershell.exe -ExecutionPolicy Bypass -File win_firewall_add_rule.ps1 -Name "CDP-9222" -Port 9222

  param(
      [Parameter(Mandatory = $true)]
      [string]$Name,

      [Parameter(Mandatory = $true)]
      [int]$Port,

      [Parameter(Mandatory = $false)]
      [string]$Protocol = "TCP"
  )

  # Verify administrator privileges
  $currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
  if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
      Write-Error "ERROR: This script requires Administrator privileges. Run PowerShell as Administrator."
      exit 1
  }

  # Validate protocol
  $validProtocols = @("TCP", "UDP")
  if ($validProtocols -notcontains $Protocol.ToUpper()) {
      Write-Error "ERROR: Protocol must be TCP or UDP, got '$Protocol'."
      exit 1
  }

  # Validate port range
  if ($Port -lt 1 -or $Port -gt 65535) {
      Write-Error "ERROR: Port must be between 1 and 65535, got '$Port'."
      exit 1
  }

  # Remove existing rule with the same name if it exists
  $existingRule = netsh advfirewall firewall show rule name="$Name" 2>&1
  if ($LASTEXITCODE -eq 0) {
      Write-Host "Removing existing rule '$Name'..."
      netsh advfirewall firewall delete rule name="$Name" | Out-Null
      if ($LASTEXITCODE -ne 0) {
          Write-Error "ERROR: Failed to remove existing rule '$Name'."
          exit 1
      }
  }

  # Add the new inbound rule
  Write-Host "Adding firewall rule '$Name' for $Protocol port $Port..."
  netsh advfirewall firewall add rule name="$Name" dir=in action=allow protocol=$Protocol localport=$Port

  if ($LASTEXITCODE -ne 0) {
      Write-Error "ERROR: Failed to add firewall rule '$Name'."
      exit 1
  }

  Write-Host "OK: Firewall rule '$Name' added - $Protocol inbound on port $Port."

params_schema:
  {"params":[{"name":"Name","desc":"Nombre de la regla en el firewall"},{"name":"Port","desc":"Puerto local a permitir (1-65535)"},{"name":"Protocol","desc":"Protocolo TCP o UDP (default: TCP)"}],"output":"Void - crea regla de entrada en firewall de Windows, idempotente por nombre"}


id: win_firewall_remove_rule_ps_infra
name: win_firewall_remove_rule
kind: function
lang: ps
domain: infra
version: 1.0.0
purity: impure
signature: win_firewall_remove_rule -Name <string>
tags: ["firewall","windows","netsh","network","infra","security","cleanup","wsl2"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: powershell/functions/infra/win_firewall_remove_rule.ps1
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 98cebf8d81ddb72d3a71bdb556d43ac0da7117b8ac622a86b79d8d4990f2f160
description:
  Elimina una regla del firewall de Windows por nombre. Si la regla no existe, termina con éxito sin hacer nada (idempotente). Requiere privilegios de Administrador.

notes:
  - Requiere ejecutarse como **Administrador** en Windows.
  - Idempotente: si la regla no existe, sale con exit code 0 y mensaje informativo.
  - Complementa `win_firewall_add_rule` para teardown limpio de reglas temporales.
  - Retorna exit code 0 si tuvo éxito (o la regla no existía), 1 si hubo error.

documentation:
  ## Uso

  ```powershell
  # Desde PowerShell (como Administrador)
  .\win_firewall_remove_rule.ps1 -Name "CDP-9222"
  ```

  ```bash
  # Desde WSL2
  powershell.exe -ExecutionPolicy Bypass -File win_firewall_remove_rule.ps1 -Name "CDP-9222"
  ```

  ## Parametros

  | Parametro | Tipo   | Obligatorio | Descripcion                       |
  |-----------|--------|-------------|-----------------------------------|
  | `-Name`   | string | si          | Nombre exacto de la regla a eliminar |

code:
  # win_firewall_remove_rule.ps1 - Removes a Windows Firewall rule by name.
  # Requires: Administrator privileges
  # Usage: powershell.exe -ExecutionPolicy Bypass -File win_firewall_remove_rule.ps1 -Name "CDP-9222"

  param(
      [Parameter(Mandatory = $true)]
      [string]$Name
  )

  # Verify administrator privileges
  $currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
  if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
      Write-Error "ERROR: This script requires Administrator privileges. Run PowerShell as Administrator."
      exit 1
  }

  # Check if the rule exists
  $existingRule = netsh advfirewall firewall show rule name="$Name" 2>&1
  if ($LASTEXITCODE -ne 0) {
      Write-Host "INFO: Firewall rule '$Name' does not exist - nothing to remove."
      exit 0
  }

  # Remove the rule
  Write-Host "Removing firewall rule '$Name'..."
  netsh advfirewall firewall delete rule name="$Name"

  if ($LASTEXITCODE -ne 0) {
      Write-Error "ERROR: Failed to remove firewall rule '$Name'."
      exit 1
  }

  Write-Host "OK: Firewall rule '$Name' removed."

params_schema:
  {"params":[{"name":"Name","desc":"Nombre exacto de la regla a eliminar"}],"output":"Void - elimina regla del firewall de Windows, idempotente (no falla si no existe)"}


id: win_portproxy_add_ps_infra
name: win_portproxy_add
kind: function
lang: ps
domain: infra
version: 1.0.0
purity: impure
tags: ["portproxy","netsh","windows","network","infra","wsl2","cdp","redirect","proxy"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: powershell/functions/infra/win_portproxy_add.ps1
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 080fffec2cd5b207b92b99f1bbd4e02c2088861f5a39f1a03e15c8b6bb2e3321
signature:
  win_portproxy_add -ListenPort <int> [-ConnectPort <int>] [-ListenAddr <string>] [-ConnectAddr <string>]

description:
  Añade una regla de port proxy v4tov4 con netsh para redirigir tráfico desde ListenAddr:ListenPort hacia ConnectAddr:ConnectPort. Si ya existe una regla para el mismo listenaddress:listenport, la elimina y la recrea. Requiere privilegios de Administrador.

notes:
  - Requiere ejecutarse como **Administrador** en Windows.
  - Caso de uso principal: WSL2 no puede alcanzar `127.0.0.1` del host Windows directamente. Esta regla hace que el host escuche en `0.0.0.0:PORT` y reenvíe al loopback donde Chrome (u otro proceso) está escuchando.
  - Idempotente respecto a listenaddress:listenport: si ya existe una regla para esa combinación, la elimina y la recrea.
  - Combinar con `win_firewall_add_rule` para que el firewall también permita el tráfico entrante.
  - Retorna exit code 0 si tuvo éxito, 1 si hubo error.

documentation:
  ## Uso

  ```powershell
  # Caso típico WSL2 → Chrome CDP: redirigir 0.0.0.0:9222 → 127.0.0.1:9222
  .\win_portproxy_add.ps1 -ListenPort 9222

  # Con todos los parámetros explícitos
  .\win_portproxy_add.ps1 -ListenPort 9222 -ConnectPort 9222 -ListenAddr 0.0.0.0 -ConnectAddr 127.0.0.1

  # Redirigir a un puerto diferente
  .\win_portproxy_add.ps1 -ListenPort 8080 -ConnectPort 3000
  ```

  ```bash
  # Desde WSL2 — habilitar acceso a Chrome CDP en el host Windows
  powershell.exe -ExecutionPolicy Bypass -File win_portproxy_add.ps1 -ListenPort 9222
  ```

  ## Parametros

  | Parametro      | Tipo   | Obligatorio | Default       | Descripcion                                      |
  |----------------|--------|-------------|---------------|--------------------------------------------------|
  | `-ListenPort`  | int    | si          | —             | Puerto en el que escucha el proxy (1-65535)      |
  | `-ConnectPort` | int    | no          | =ListenPort   | Puerto de destino al que conectar (1-65535)      |
  | `-ListenAddr`  | string | no          | `0.0.0.0`     | Dirección IP en la que escucha el proxy          |
  | `-ConnectAddr` | string | no          | `127.0.0.1`   | Dirección IP de destino                          |

  ## Flujo completo WSL2 → Chrome CDP

  ```bash
  # 1. Abrir Puerto en firewall
  powershell.exe -ExecutionPolicy Bypass -File win_firewall_add_rule.ps1 -Name "CDP-9222" -Port 9222

  # 2. Redirigir tráfico de red al loopback
  powershell.exe -ExecutionPolicy Bypass -File win_portproxy_add.ps1 -ListenPort 9222

  # 3. Arrancar Chrome con CDP en Windows
  # chrome.exe --remote-debugging-port=9222 --headless

  # 4. Desde WSL2 conectar al host Windows
  # WSL2_HOST=$(cat /etc/resolv.conf | grep nameserver | awk '{print $2}')
  # curl http://$WSL2_HOST:9222/json
  ```

code:
  # win_portproxy_add.ps1 - Adds a netsh portproxy rule (v4tov4) to forward traffic.
  # Requires: Administrator privileges
  # Usage: powershell.exe -ExecutionPolicy Bypass -File win_portproxy_add.ps1 -ListenPort 9222
  #         powershell.exe -ExecutionPolicy Bypass -File win_portproxy_add.ps1 -ListenPort 9222 -ConnectPort 9222 -ListenAddr 0.0.0.0 -ConnectAddr 127.0.0.1

  param(
      [Parameter(Mandatory = $true)]
      [int]$ListenPort,

      [Parameter(Mandatory = $false)]
      [int]$ConnectPort = 0,  # 0 means use ListenPort

      [Parameter(Mandatory = $false)]
      [string]$ListenAddr = "0.0.0.0",

      [Parameter(Mandatory = $false)]
      [string]$ConnectAddr = "127.0.0.1"
  )

  # Verify administrator privileges
  $currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
  if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
      Write-Error "ERROR: This script requires Administrator privileges. Run PowerShell as Administrator."
      exit 1
  }

  # Default ConnectPort to ListenPort if not specified
  if ($ConnectPort -eq 0) {
      $ConnectPort = $ListenPort
  }

  # Validate ports
  if ($ListenPort -lt 1 -or $ListenPort -gt 65535) {
      Write-Error "ERROR: ListenPort must be between 1 and 65535, got '$ListenPort'."
      exit 1
  }
  if ($ConnectPort -lt 1 -or $ConnectPort -gt 65535) {
      Write-Error "ERROR: ConnectPort must be between 1 and 65535, got '$ConnectPort'."
      exit 1
  }

  # Remove existing portproxy for the same listenaddress:listenport if it exists
  $existing = netsh interface portproxy show v4tov4 2>&1 | Select-String "$ListenAddr\s+$ListenPort"
  if ($existing) {
      Write-Host "Removing existing portproxy for ${ListenAddr}:${ListenPort}..."
      netsh interface portproxy delete v4tov4 listenaddress=$ListenAddr listenport=$ListenPort | Out-Null
      if ($LASTEXITCODE -ne 0) {
          Write-Error "ERROR: Failed to remove existing portproxy for ${ListenAddr}:${ListenPort}."
          exit 1
      }
  }

  # Add the portproxy rule
  Write-Host "Adding portproxy: ${ListenAddr}:${ListenPort} -> ${ConnectAddr}:${ConnectPort}..."
  netsh interface portproxy add v4tov4 listenaddress=$ListenAddr listenport=$ListenPort connectaddress=$ConnectAddr connectport=$ConnectPort

  if ($LASTEXITCODE -ne 0) {
      Write-Error "ERROR: Failed to add portproxy ${ListenAddr}:${ListenPort} -> ${ConnectAddr}:${ConnectPort}."
      exit 1
  }

  Write-Host "OK: Portproxy added - ${ListenAddr}:${ListenPort} -> ${ConnectAddr}:${ConnectPort}."

params_schema:
  {"params":[{"name":"ListenPort","desc":"Puerto en el que escucha el proxy (1-65535)"},{"name":"ConnectPort","desc":"Puerto de destino al que conectar (default: =ListenPort)"},{"name":"ListenAddr","desc":"Dirección IP en la que escucha (default: 0.0.0.0)"},{"name":"ConnectAddr","desc":"Dirección IP de destino (default: 127.0.0.1)"}],"output":"Void - crea regla de port proxy v4tov4 vía netsh, idempotente por ListenAddr:ListenPort"}


id: win_portproxy_remove_ps_infra
name: win_portproxy_remove
kind: function
lang: ps
domain: infra
version: 1.0.0
purity: impure
signature: win_portproxy_remove -ListenPort <int> [-ListenAddr <string>]
tags: ["portproxy","netsh","windows","network","infra","wsl2","cleanup","proxy"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: powershell/functions/infra/win_portproxy_remove.ps1
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 8b17027ae10bb417a9405c13d42ede67f8d0aefad27e2b2edf044c03be0e6332
description:
  Elimina una regla de port proxy v4tov4 de netsh identificada por ListenAddr:ListenPort. Si la regla no existe, termina con éxito sin hacer nada (idempotente). Requiere privilegios de Administrador.

notes:
  - Requiere ejecutarse como **Administrador** en Windows.
  - Idempotente: si la regla no existe, sale con exit code 0 y mensaje informativo.
  - Complementa `win_portproxy_add` para teardown limpio.
  - La regla se identifica por la combinación `listenaddress:listenport` — debe coincidir exactamente con la usada en `win_portproxy_add`.
  - Retorna exit code 0 si tuvo éxito (o la regla no existía), 1 si hubo error.

documentation:
  ## Uso

  ```powershell
  # Eliminar portproxy en puerto 9222 (listenaddr por defecto: 0.0.0.0)
  .\win_portproxy_remove.ps1 -ListenPort 9222

  # Con listenaddr explícito
  .\win_portproxy_remove.ps1 -ListenAddr 0.0.0.0 -ListenPort 9222
  ```

  ```bash
  # Desde WSL2
  powershell.exe -ExecutionPolicy Bypass -File win_portproxy_remove.ps1 -ListenPort 9222
  ```

  ## Parametros

  | Parametro     | Tipo   | Obligatorio | Default     | Descripcion                                          |
  |---------------|--------|-------------|-------------|------------------------------------------------------|
  | `-ListenPort` | int    | si          | —           | Puerto de escucha de la regla a eliminar (1-65535)   |
  | `-ListenAddr` | string | no          | `0.0.0.0`   | Dirección IP de escucha de la regla a eliminar       |

  ## Teardown completo WSL2 → Chrome CDP

  ```bash
  # 1. Eliminar portproxy
  powershell.exe -ExecutionPolicy Bypass -File win_portproxy_remove.ps1 -ListenPort 9222

  # 2. Eliminar regla de firewall
  powershell.exe -ExecutionPolicy Bypass -File win_firewall_remove_rule.ps1 -Name "CDP-9222"
  ```

code:
  # win_portproxy_remove.ps1 - Removes a netsh portproxy v4tov4 rule.
  # Requires: Administrator privileges
  # Usage: powershell.exe -ExecutionPolicy Bypass -File win_portproxy_remove.ps1 -ListenPort 9222
  #         powershell.exe -ExecutionPolicy Bypass -File win_portproxy_remove.ps1 -ListenAddr 0.0.0.0 -ListenPort 9222

  param(
      [Parameter(Mandatory = $true)]
      [int]$ListenPort,

      [Parameter(Mandatory = $false)]
      [string]$ListenAddr = "0.0.0.0"
  )

  # Verify administrator privileges
  $currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
  if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
      Write-Error "ERROR: This script requires Administrator privileges. Run PowerShell as Administrator."
      exit 1
  }

  # Validate port
  if ($ListenPort -lt 1 -or $ListenPort -gt 65535) {
      Write-Error "ERROR: ListenPort must be between 1 and 65535, got '$ListenPort'."
      exit 1
  }

  # Check if the portproxy rule exists
  $existing = netsh interface portproxy show v4tov4 2>&1 | Select-String "$ListenAddr\s+$ListenPort"
  if (-not $existing) {
      Write-Host "INFO: Portproxy for ${ListenAddr}:${ListenPort} does not exist - nothing to remove."
      exit 0
  }

  # Remove the portproxy rule
  Write-Host "Removing portproxy for ${ListenAddr}:${ListenPort}..."
  netsh interface portproxy delete v4tov4 listenaddress=$ListenAddr listenport=$ListenPort

  if ($LASTEXITCODE -ne 0) {
      Write-Error "ERROR: Failed to remove portproxy for ${ListenAddr}:${ListenPort}."
      exit 1
  }

  Write-Host "OK: Portproxy for ${ListenAddr}:${ListenPort} removed."

params_schema:
  {"params":[{"name":"ListenPort","desc":"Puerto de escucha de la regla a eliminar (1-65535)"},{"name":"ListenAddr","desc":"Dirección IP de escucha (default: 0.0.0.0)"}],"output":"Void - elimina regla de port proxy, idempotente (no falla si no existe)"}


id: bq_auth_py_infra
name: bq_auth
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def bq_auth(project_id: str = '', credentials_path: str = '') -> BQClient
tags: ["bigquery","gcp","auth","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/client.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: ea652c31b5a87d8d48926920f84dc409e492ea0c59f9e16f0569c25685f5580b
description:
  Autentica contra Google BigQuery con ADC o service account JSON. Retorna un BQClient listo para usar con todas las funciones CRUD.

example:
  ```python
  from bigquery import bq_auth

  # ADC (gcloud auth application-default login)
  client = bq_auth()

  # Proyecto explicito
  client = bq_auth("my-project-id")

  # Service account
  client = bq_auth(credentials_path="/path/to/service-account.json")

  # Context manager
  with bq_auth() as client:
      # client se cierra automaticamente
      pass
  ```

notes:
  Tres modos de autenticacion:
  - Sin argumentos: usa Application Default Credentials (ADC) — requiere `gcloud auth application-default login`
  - Con project_id: usa ADC pero fuerza el proyecto
  - Con credentials_path: lee el JSON de service account directamente

  El BQClient wrappea `google.cloud.bigquery.Client` y expone `_client` para que las funciones del modulo lo usen internamente.

code:
  """Cliente base para Google BigQuery."""

  from dataclasses import dataclass, field
  from google.cloud import bigquery
  from google.oauth2 import service_account


  @dataclass
  class BQClient:
      """Cliente para Google BigQuery.

      Attributes:
          project_id: ID del proyecto GCP.
          _client: Cliente oficial de BigQuery (interno).
      """
      project_id: str
      _client: bigquery.Client = field(repr=False)

      def close(self) -> None:
          """Cierra el cliente BigQuery."""
          self._client.close()

      def __enter__(self):
          return self

      def __exit__(self, *args):
          self.close()


  def bq_auth(project_id: str = "", credentials_path: str = "") -> BQClient:
      """Autentica contra Google BigQuery.

      Tres modos de autenticacion:
      1. ADC (Application Default Credentials): sin argumentos, usa gcloud auth
      2. Service account JSON: con credentials_path
      3. Proyecto explicito: con project_id (usa ADC para credenciales)

      Args:
          project_id: ID del proyecto GCP. Vacio = detectar de credenciales.
          credentials_path: Ruta a archivo JSON de service account. Vacio = ADC.

      Returns:
          BQClient autenticado listo para usar.

      Raises:
          google.auth.exceptions.DefaultCredentialsError: Si no hay credenciales configuradas.
          FileNotFoundError: Si credentials_path no existe.

      Example:
          >>> client = bq_auth()  # ADC
          >>> client = bq_auth("my-project")  # ADC con proyecto explicito
          >>> client = bq_auth(credentials_path="/path/to/sa.json")  # Service account
      """
      if credentials_path:
          creds = service_account.Credentials.from_service_account_file(credentials_path)
          proj = project_id or creds.project_id
          client = bigquery.Client(credentials=creds, project=proj)
      elif project_id:
          client = bigquery.Client(project=project_id)
      else:
          client = bigquery.Client()

      return BQClient(project_id=client.project, _client=client)

params_schema:
  {"params":[{"name":"project_id","desc":"ID del proyecto GCP (vacio = detectar de credenciales/entorno)"},{"name":"credentials_path","desc":"ruta a archivo JSON de service account (vacio = Application Default Credentials)"}],"output":"BQClient: cliente autenticado con proyecto resuelto"}


id: bq_cancel_job_py_infra
name: bq_cancel_job
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def bq_cancel_job(client: BQClient, job_id: str) -> dict
description: Cancela un job en ejecucion. Retorna el estado tras la cancelacion.
tags: ["bigquery","gcp","job","cancel","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/jobs.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: f5f1c8b57f5ad7b83c5647722e2328bd56e2d8acb6d569eb001fb5c32fb50d71
example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.jobs import bq_list_jobs, bq_cancel_job

  client = bq_auth("my-project")

  # Cancelar todos los jobs en ejecucion
  running = bq_list_jobs(client, state_filter="running")
  for job in running:
      result = bq_cancel_job(client, job["job_id"])
      print(f"Cancelado {result['job_id']}: {result['state']}")
  ```

notes:
  Lanza `google.api_core.exceptions.NotFound` si el job_id no existe en el proyecto.

  BigQuery no garantiza cancelacion inmediata: el job puede seguir procesando brevemente antes de detenerse. El estado retornado refleja el estado al momento de la respuesta de la API, que tipicamente es `"DONE"`.

  Cancelar un job ya completado o ya cancelado no genera error; la API lo acepta de forma idempotente y retorna el estado actual.

  Solo el usuario que creo el job o un administrador del proyecto puede cancelarlo.

code:
  """Gestion de jobs en Google BigQuery."""

  from .client import BQClient


  def bq_list_jobs(
      client: BQClient,
      state_filter: str = "",
      max_results: int = 50,
      all_users: bool = False,
  ) -> list[dict]:
      """Lista jobs del proyecto con filtro opcional por estado.

      Args:
          client: Cliente autenticado.
          state_filter: Filtro: "running", "pending", "done". Vacio = todos.
          max_results: Numero maximo de jobs a retornar.
          all_users: Si True, lista jobs de todos los usuarios (requiere permisos).

      Returns:
          Lista de dicts con: job_id, job_type, state, created, started, ended,
          user_email, bytes_processed, error.

      Example:
          >>> jobs = bq_list_jobs(client, state_filter="running")
          >>> for j in jobs:
          ...     print(j["job_id"], j["state"], j["job_type"])
      """
      kwargs = {"max_results": max_results, "all_users": all_users}
      if state_filter:
          kwargs["state_filter"] = state_filter
      return [_job_to_dict(job) for job in client._client.list_jobs(**kwargs)]


  def bq_get_job(client: BQClient, job_id: str) -> dict:
      """Obtiene detalles de un job por su ID.

      Args:
          client: Cliente autenticado.
          job_id: ID del job.

      Returns:
          Dict con: job_id, job_type, state, created, started, ended,
          user_email, bytes_processed, destination_table, query, error, errors.

      Raises:
          google.api_core.exceptions.NotFound: Si el job no existe.

      Example:
          >>> job = bq_get_job(client, "job_abc123")
          >>> print(job["state"], job["bytes_processed"])
          >>> if job["error"]:
          ...     print("Error:", job["error"])
      """
      job = client._client.get_job(job_id)
      result = _job_to_dict(job)
      # Agregar detalles extra disponibles en get pero no en list
      if hasattr(job, 'destination') and job.destination:
          result["destination_table"] = str(job.destination)
      if hasattr(job, 'query') and job.query:
          result["query"] = job.query
      if job.errors:
          result["errors"] = [dict(e) for e in job.errors]
      return result


  def bq_cancel_job(client: BQClient, job_id: str) -> dict:
      """Cancela un job en ejecucion.

      Args:
          client: Cliente autenticado.
          job_id: ID del job a cancelar.

      Returns:
          Dict con: job_id, state (tras cancelacion).

      Raises:
          google.api_core.exceptions.NotFound: Si el job no existe.

      Example:
          >>> result = bq_cancel_job(client, "job_abc123")
          >>> print(result["state"])  # "DONE" (cancelled)
      """
      job = client._client.cancel_job(job_id)
      return {"job_id": job.job_id, "state": job.state}


  def _job_to_dict(job) -> dict:
      """Convierte un objeto Job del SDK a dict plano."""
      result = {
          "job_id": job.job_id,
          "job_type": job.job_type,
          "state": job.state,
          "created": job.created.isoformat() if job.created else None,
          "started": job.started.isoformat() if job.started else None,
          "ended": job.ended.isoformat() if job.ended else None,
          "user_email": job.user_email,
          "error": str(job.error_result) if job.error_result else None,
      }
      # bytes_processed solo disponible en query jobs
      try:
          result["bytes_processed"] = job.total_bytes_processed
      except AttributeError:
          result["bytes_processed"] = None
      return result

params_schema:
  {"params":[{"name":"client","desc":"cliente autenticado de BigQuery obtenido con bq_auth"},{"name":"job_id","desc":"ID del job a cancelar (formato: 'proyecto:region.job_id' o solo 'job_id')"}],"output":"dict con campos: job_id (string), state (string, tipicamente 'DONE' tras la cancelacion)"}


id: bq_copy_table_py_infra
name: bq_copy_table
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["bigquery","gcp","copy","table","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/queries.py
created_at: 2026-04-07T09:57:08Z
updated_at: 2026-04-07T09:57:08Z
props: []
emits: []
variant: []
content_hash: 615ce86ddeb90633de7475e1f8f0c0d983213aa0baa718da20d07cb2c8b46360
signature:
  def bq_copy_table(client: BQClient, source_dataset: str, source_table: str, dest_dataset: str, dest_table: str, write_disposition: str = 'WRITE_EMPTY') -> dict

description:
  Copia una tabla BigQuery a otro dataset o tabla dentro del mismo proyecto usando copy_table del SDK. Espera la finalizacion del CopyJob.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.queries import bq_copy_table

  client = bq_auth("my-project")

  # Copia de produccion a staging (falla si ya existe)
  result = bq_copy_table(
      client,
      "production", "users",
      "staging",   "users_backup",
  )
  print(f"Copia completada: {result['status']} — job: {result['job_id']}")

  # Copia sobreescribiendo destino
  result = bq_copy_table(
      client,
      "raw", "events_2024",
      "processed", "events_latest",
      write_disposition="WRITE_TRUNCATE",
  )
  ```

notes:
  `copy_table` solo funciona dentro del mismo proyecto GCP. Para copiar entre proyectos,
  usar `bq_export_to_gcs` + `bq_load_from_gcs`.

  Si la tabla destino no existe, BigQuery la crea con el schema de la tabla origen
  independientemente del `write_disposition`.

  El CopyJob es asincrono; `job.result()` bloquea hasta completar. La copia no mueve
  datos fisicamente — BigQuery usa referencias de bloques internamente hasta que se
  modifica la copia.

code:
  """Queries y operaciones de datos para Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  def bq_query(
      client: BQClient,
      sql: str,
      params: list[dict] | None = None,
      dry_run: bool = False,
  ) -> dict:
      """Ejecuta una query SQL en BigQuery con soporte para parametros y dry-run.

      Si dry_run=True, estima el costo sin ejecutar la query. Si params no es
      None, los convierte a ScalarQueryParameter usando la sintaxis @nombre en SQL.

      Args:
          client: Cliente autenticado de BigQuery.
          sql: Query SQL a ejecutar. Usar @nombre para referencias a parametros.
          params: Lista de parametros. Cada elemento: {"name": "x", "type": "STRING", "value": "hello"}.
                  Tipos soportados: STRING, INT64, FLOAT64, BOOL, DATE, TIMESTAMP.
          dry_run: Si True, retorna estimacion de bytes sin ejecutar.

      Returns:
          Si dry_run=True: {"total_bytes_processed": N, "total_bytes_billed": N}.
          Si dry_run=False: {"columns": [...], "rows": [[...], ...],
                             "total_rows": N, "bytes_processed": N, "cache_hit": bool}.

      Raises:
          google.api_core.exceptions.GoogleAPICallError: Si la query falla.
          ValueError: Si un tipo de parametro no es soportado.

      Example:
          >>> result = bq_query(client, "SELECT COUNT(*) as n FROM `project.dataset.table`")
          >>> print(result["rows"])
          >>> # Con parametros:
          >>> result = bq_query(client, "SELECT * FROM t WHERE status = @s",
          ...     params=[{"name": "s", "type": "STRING", "value": "active"}])
      """
      job_config = bigquery.QueryJobConfig()

      if params:
          query_params = []
          for p in params:
              query_params.append(
                  bigquery.ScalarQueryParameter(p["name"], p["type"], p["value"])
              )
          job_config.query_parameters = query_params

      if dry_run:
          job_config.dry_run = True
          job_config.use_query_cache = False
          job = client._client.query(sql, job_config=job_config)
          return {
              "total_bytes_processed": job.total_bytes_processed,
              "total_bytes_billed": job.total_bytes_billed,
          }

      job = client._client.query(sql, job_config=job_config)
      result = job.result()

      columns = [field.name for field in result.schema]
      rows = [list(row.values()) for row in result]

      return {
          "columns": columns,
          "rows": rows,
          "total_rows": result.total_rows,
          "bytes_processed": job.total_bytes_processed,
          "cache_hit": job.cache_hit,
      }


  def bq_insert_rows(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      rows: list[dict],
  ) -> dict:
      """Inserta filas en una tabla BigQuery usando el streaming insert.

      Usa la API de streaming (insert_rows_json) para insercion en tiempo real.
      Los errores de filas individuales se retornan en el campo "errors" sin
      lanzar excepcion — revisar siempre ese campo.

      Args:
          client: Cliente autenticado de BigQuery.
          dataset_id: ID del dataset de destino.
          table_id: ID de la tabla de destino.
          rows: Lista de dicts con los datos a insertar. Cada dict debe tener
                claves que coincidan con las columnas de la tabla.

      Returns:
          {"inserted": N, "errors": [...]} donde errors es la lista de errores
          de streaming insert retornada por la API (vacia si todo OK).

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.
          google.api_core.exceptions.GoogleAPICallError: Si la API falla.

      Example:
          >>> result = bq_insert_rows(client, "my_dataset", "my_table", [
          ...     {"id": 1, "name": "Alice", "created_at": "2024-01-01"},
          ...     {"id": 2, "name": "Bob",   "created_at": "2024-01-02"},
          ... ])
          >>> if result["errors"]:
          ...     print("Errores:", result["errors"])
      """
      table_ref = client._client.dataset(dataset_id).table(table_id)
      errors = client._client.insert_rows_json(table_ref, rows)
      return {
          "inserted": len(rows) - len(errors),
          "errors": errors,
      }


  def bq_load_from_gcs(
      client: BQClient,
      uri: str | list[str],
      dataset_id: str,
      table_id: str,
      source_format: str = "CSV",
      write_disposition: str = "WRITE_APPEND",
      autodetect: bool = True,
      skip_leading_rows: int = 0,
  ) -> dict:
      """Carga datos desde Google Cloud Storage a una tabla BigQuery.

      Configura y ejecuta un LoadJob desde uno o varios URIs de GCS. Espera
      la finalizacion del job con job.result().

      Args:
          client: Cliente autenticado de BigQuery.
          uri: URI de GCS. Puede ser un string ("gs://bucket/file.csv") o una
               lista de strings para multiples archivos.
          dataset_id: ID del dataset de destino.
          table_id: ID de la tabla de destino.
          source_format: Formato del archivo: "CSV", "NEWLINE_DELIMITED_JSON",
                         "AVRO", "PARQUET", "ORC". Default: "CSV".
          write_disposition: Comportamiento si la tabla existe: "WRITE_APPEND",
                             "WRITE_TRUNCATE", "WRITE_EMPTY". Default: "WRITE_APPEND".
          autodetect: Si True, detecta el schema automaticamente. Default: True.
          skip_leading_rows: Numero de filas a saltar al inicio (cabeceras CSV).

      Returns:
          {"job_id": str, "rows_loaded": N, "status": "DONE"|"FAILED"}.

      Raises:
          google.api_core.exceptions.GoogleAPICallError: Si el job falla.
          google.cloud.exceptions.NotFound: Si el bucket o dataset no existe.

      Example:
          >>> result = bq_load_from_gcs(
          ...     client, "gs://my-bucket/data/*.csv",
          ...     "my_dataset", "my_table",
          ...     skip_leading_rows=1,
          ... )
          >>> print(f"Cargadas {result['rows_loaded']} filas, job: {result['job_id']}")
      """
      format_map = {
          "CSV": bigquery.SourceFormat.CSV,
          "NEWLINE_DELIMITED_JSON": bigquery.SourceFormat.NEWLINE_DELIMITED_JSON,
          "AVRO": bigquery.SourceFormat.AVRO,
          "PARQUET": bigquery.SourceFormat.PARQUET,
          "ORC": bigquery.SourceFormat.ORC,
      }
      disposition_map = {
          "WRITE_APPEND": bigquery.WriteDisposition.WRITE_APPEND,
          "WRITE_TRUNCATE": bigquery.WriteDisposition.WRITE_TRUNCATE,
          "WRITE_EMPTY": bigquery.WriteDisposition.WRITE_EMPTY,
      }

      job_config = bigquery.LoadJobConfig(
          source_format=format_map.get(source_format, bigquery.SourceFormat.CSV),
          write_disposition=disposition_map.get(source_format, bigquery.WriteDisposition.WRITE_APPEND),
          autodetect=autodetect,
          skip_leading_rows=skip_leading_rows,
      )
      job_config.write_disposition = disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND)

      table_ref = client._client.dataset(dataset_id).table(table_id)
      uris = uri if isinstance(uri, list) else [uri]

      job = client._client.load_table_from_uri(uris, table_ref, job_config=job_config)
      result = job.result()

      return {
          "job_id": job.job_id,
          "rows_loaded": result.output_rows,
          "status": "DONE" if job.state == "DONE" and not job.errors else "FAILED",
      }


  def bq_load_from_file(
      client: BQClient,
      file_path: str,
      dataset_id: str,
      table_id: str,
      source_format: str = "CSV",
      write_disposition: str = "WRITE_APPEND",
      autodetect: bool = True,
      skip_leading_rows: int = 0,
  ) -> dict:
      """Carga datos desde un archivo local a una tabla BigQuery.

      Abre el archivo y usa load_table_from_file del SDK. Equivalente a
      bq_load_from_gcs pero para archivos en disco local.

      Args:
          client: Cliente autenticado de BigQuery.
          file_path: Ruta absoluta o relativa al archivo local a cargar.
          dataset_id: ID del dataset de destino.
          table_id: ID de la tabla de destino.
          source_format: Formato del archivo: "CSV", "NEWLINE_DELIMITED_JSON",
                         "AVRO", "PARQUET", "ORC". Default: "CSV".
          write_disposition: Comportamiento si la tabla existe: "WRITE_APPEND",
                             "WRITE_TRUNCATE", "WRITE_EMPTY". Default: "WRITE_APPEND".
          autodetect: Si True, detecta el schema automaticamente. Default: True.
          skip_leading_rows: Numero de filas a saltar al inicio (cabeceras CSV).

      Returns:
          {"job_id": str, "rows_loaded": N, "status": "DONE"|"FAILED"}.

      Raises:
          FileNotFoundError: Si el archivo local no existe.
          google.api_core.exceptions.GoogleAPICallError: Si el job falla.

      Example:
          >>> result = bq_load_from_file(
          ...     client, "/tmp/data.csv",
          ...     "my_dataset", "my_table",
          ...     skip_leading_rows=1,
          ... )
          >>> print(f"Cargadas {result['rows_loaded']} filas")
      """
      format_map = {
          "CSV": bigquery.SourceFormat.CSV,
          "NEWLINE_DELIMITED_JSON": bigquery.SourceFormat.NEWLINE_DELIMITED_JSON,
          "AVRO": bigquery.SourceFormat.AVRO,
          "PARQUET": bigquery.SourceFormat.PARQUET,
          "ORC": bigquery.SourceFormat.ORC,
      }
      disposition_map = {
          "WRITE_APPEND": bigquery.WriteDisposition.WRITE_APPEND,
          "WRITE_TRUNCATE": bigquery.WriteDisposition.WRITE_TRUNCATE,
          "WRITE_EMPTY": bigquery.WriteDisposition.WRITE_EMPTY,
      }

      job_config = bigquery.LoadJobConfig(
          source_format=format_map.get(source_format, bigquery.SourceFormat.CSV),
          write_disposition=disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND),
          autodetect=autodetect,
          skip_leading_rows=skip_leading_rows,
      )

      table_ref = client._client.dataset(dataset_id).table(table_id)

      with open(file_path, "rb") as f:
          job = client._client.load_table_from_file(f, table_ref, job_config=job_config)

      result = job.result()

      return {
          "job_id": job.job_id,
          "rows_loaded": result.output_rows,
          "status": "DONE" if job.state == "DONE" and not job.errors else "FAILED",
      }


  def bq_export_to_gcs(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      destination_uri: str,
      destination_format: str = "CSV",
      compression: str = "NONE",
  ) -> dict:
      """Exporta una tabla BigQuery a Google Cloud Storage.

      Usa extract_table del SDK para crear un ExtractJob. Espera la finalizacion
      del job con job.result().

      Args:
          client: Cliente autenticado de BigQuery.
          dataset_id: ID del dataset de origen.
          table_id: ID de la tabla de origen.
          destination_uri: URI de destino en GCS. Para multiples archivos usar
                           wildcard: "gs://bucket/prefix_*.csv".
          destination_format: Formato de exportacion: "CSV", "NEWLINE_DELIMITED_JSON",
                              "AVRO", "PARQUET". Default: "CSV".
          compression: Compresion: "NONE", "GZIP", "SNAPPY", "DEFLATE". Default: "NONE".

      Returns:
          {"job_id": str, "destination_uri": str, "status": "DONE"|"FAILED"}.

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.
          google.api_core.exceptions.GoogleAPICallError: Si el job falla.

      Example:
          >>> result = bq_export_to_gcs(
          ...     client, "my_dataset", "my_table",
          ...     "gs://my-bucket/export/data_*.csv",
          ...     compression="GZIP",
          ... )
          >>> print(f"Exportado a {result['destination_uri']}, job: {result['job_id']}")
      """
      format_map = {
          "CSV": bigquery.DestinationFormat.CSV,
          "NEWLINE_DELIMITED_JSON": bigquery.DestinationFormat.NEWLINE_DELIMITED_JSON,
          "AVRO": bigquery.DestinationFormat.AVRO,
          "PARQUET": bigquery.DestinationFormat.PARQUET,
      }
      compression_map = {
          "NONE": bigquery.Compression.NONE,
          "GZIP": bigquery.Compression.GZIP,
          "SNAPPY": bigquery.Compression.SNAPPY,
          "DEFLATE": bigquery.Compression.DEFLATE,
      }

      job_config = bigquery.ExtractJobConfig(
          destination_format=format_map.get(destination_format, bigquery.DestinationFormat.CSV),
          compression=compression_map.get(compression, bigquery.Compression.NONE),
      )

      table_ref = client._client.dataset(dataset_id).table(table_id)
      job = client._client.extract_table(table_ref, destination_uri, job_config=job_config)
      job.result()

      return {
          "job_id": job.job_id,
          "destination_uri": destination_uri,
          "status": "DONE" if job.state == "DONE" and not job.errors else "FAILED",
      }


  def bq_copy_table(
      client: BQClient,
      source_dataset: str,
      source_table: str,
      dest_dataset: str,
      dest_table: str,
      write_disposition: str = "WRITE_EMPTY",
  ) -> dict:
      """Copia una tabla BigQuery a otro dataset o tabla dentro del mismo proyecto.

      Usa copy_table del SDK para crear un CopyJob. Espera la finalizacion
      del job con job.result(). No copia datos entre proyectos distintos.

      Args:
          client: Cliente autenticado de BigQuery.
          source_dataset: ID del dataset de origen.
          source_table: ID de la tabla de origen.
          dest_dataset: ID del dataset de destino.
          dest_table: ID de la tabla de destino.
          write_disposition: Comportamiento si la tabla destino existe: "WRITE_EMPTY"
                             (falla si existe), "WRITE_APPEND", "WRITE_TRUNCATE".
                             Default: "WRITE_EMPTY".

      Returns:
          {"job_id": str, "status": "DONE"|"FAILED"}.

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla de origen no existe.
          google.api_core.exceptions.Conflict: Si destino existe y write_disposition es WRITE_EMPTY.
          google.api_core.exceptions.GoogleAPICallError: Si el job falla.

      Example:
          >>> result = bq_copy_table(
          ...     client,
          ...     "production", "users",
          ...     "staging", "users_backup",
          ...     write_disposition="WRITE_TRUNCATE",
          ... )
          >>> print(f"Copia completada: {result['status']}, job: {result['job_id']}")
      """
      disposition_map = {
          "WRITE_APPEND": bigquery.WriteDisposition.WRITE_APPEND,
          "WRITE_TRUNCATE": bigquery.WriteDisposition.WRITE_TRUNCATE,
          "WRITE_EMPTY": bigquery.WriteDisposition.WRITE_EMPTY,
      }

      job_config = bigquery.CopyJobConfig(
          write_disposition=disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_EMPTY),
      )

      source_ref = client._client.dataset(source_dataset).table(source_table)
      dest_ref = client._client.dataset(dest_dataset).table(dest_table)

      job = client._client.copy_table(source_ref, dest_ref, job_config=job_config)
      job.result()

      return {
          "job_id": job.job_id,
          "status": "DONE" if job.state == "DONE" and not job.errors else "FAILED",
      }

params_schema:
  {"params":[{"name":"client","desc":"cliente BQClient autenticado contra el proyecto GCP"},{"name":"source_dataset","desc":"ID del dataset de origen en BigQuery"},{"name":"source_table","desc":"ID de la tabla de origen en BigQuery"},{"name":"dest_dataset","desc":"ID del dataset de destino en BigQuery (puede ser el mismo que el origen)"},{"name":"dest_table","desc":"ID de la tabla de destino; si no existe, BigQuery la crea automaticamente"},{"name":"write_disposition","desc":"comportamiento si la tabla destino ya existe: WRITE_EMPTY falla, WRITE_APPEND agrega, WRITE_TRUNCATE sobreescribe"}],"output":"dict con {job_id: ID del CopyJob, status: DONE o FAILED}"}


id: bq_create_dataset_py_infra
name: bq_create_dataset
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["bigquery","gcp","dataset","create","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/datasets.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 1164010f02ffc9624eb1ee057a44fed4cad8e678350f33382d9b3ddc7a8e784f
signature:
  def bq_create_dataset(client: BQClient, dataset_id: str, location: str = 'US', description: str = '', labels: dict[str, str] | None = None, default_table_expiration_ms: int = 0) -> dict

description:
  Crea un dataset en Google BigQuery con ubicacion, descripcion y labels. Usa client._client.create_dataset() del SDK oficial.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.datasets import bq_create_dataset

  client = bq_auth("my-project")
  ds = bq_create_dataset(
      client,
      "analytics",
      location="EU",
      description="Data warehouse principal",
      labels={"env": "prod", "team": "data"},
  )
  print(ds["full_id"], ds["location"])
  # my-project.analytics EU
  ```

notes:
  Lanza `google.api_core.exceptions.Conflict` (409) si el dataset ya existe.
  El `full_id` tiene formato `{project}.{dataset_id}` y puede usarse directamente como referencia en otras llamadas al SDK.
  `default_table_expiration_ms` aplica a todas las tablas nuevas del dataset; las tablas existentes no se ven afectadas.

code:
  """CRUD de datasets en Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  def bq_create_dataset(
      client: BQClient,
      dataset_id: str,
      location: str = "US",
      description: str = "",
      labels: dict[str, str] | None = None,
      default_table_expiration_ms: int = 0,
  ) -> dict:
      """Crea un dataset en BigQuery.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset (solo el nombre, sin proyecto).
          location: Ubicacion geografica (US, EU, us-central1, etc.).
          description: Descripcion opcional.
          labels: Labels key-value opcionales.
          default_table_expiration_ms: Expiracion por defecto de tablas en ms. 0 = sin expiracion.

      Returns:
          Dict con: dataset_id, project, full_id, location, description, labels, created, modified,
          default_table_expiration_ms.

      Raises:
          google.api_core.exceptions.Conflict: Si el dataset ya existe (409).

      Example:
          >>> ds = bq_create_dataset(client, "analytics", location="EU", description="Data warehouse")
      """
      ref = f"{client.project_id}.{dataset_id}"
      ds = bigquery.Dataset(ref)
      ds.location = location
      if description:
          ds.description = description
      if labels:
          ds.labels = labels
      if default_table_expiration_ms > 0:
          ds.default_table_expiration_ms = default_table_expiration_ms
      created = client._client.create_dataset(ds)
      return _dataset_to_dict(created)


  def bq_get_dataset(client: BQClient, dataset_id: str) -> dict:
      """Obtiene los detalles de un dataset.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.

      Returns:
          Dict con: dataset_id, project, full_id, location, description, labels,
          created, modified, default_table_expiration_ms.

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe (404).

      Example:
          >>> ds = bq_get_dataset(client, "analytics")
          >>> print(ds["location"], ds["description"])
      """
      ref = f"{client.project_id}.{dataset_id}"
      ds = client._client.get_dataset(ref)
      return _dataset_to_dict(ds)


  def bq_list_datasets(client: BQClient) -> list[dict]:
      """Lista todos los datasets del proyecto.

      Args:
          client: Cliente autenticado.

      Returns:
          Lista de dicts con: dataset_id, project, full_id.

      Example:
          >>> datasets = bq_list_datasets(client)
          >>> for ds in datasets:
          ...     print(ds["dataset_id"], ds["full_id"])
      """
      return [
          {
              "dataset_id": ds.dataset_id,
              "project": ds.project,
              "full_id": f"{ds.project}.{ds.dataset_id}",
          }
          for ds in client._client.list_datasets()
      ]


  def bq_update_dataset(
      client: BQClient,
      dataset_id: str,
      description: str | None = None,
      labels: dict[str, str] | None = None,
      default_table_expiration_ms: int | None = None,
  ) -> dict:
      """Actualiza campos de un dataset.

      Solo se modifican los campos pasados (no-None).

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.
          description: Nueva descripcion (None = no cambiar).
          labels: Nuevos labels (None = no cambiar).
          default_table_expiration_ms: Nueva expiracion de tablas en ms (None = no cambiar).

      Returns:
          Dict con el dataset actualizado.

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe.

      Example:
          >>> bq_update_dataset(client, "analytics", description="Updated description")
      """
      ref = f"{client.project_id}.{dataset_id}"
      ds = client._client.get_dataset(ref)
      fields = []
      if description is not None:
          ds.description = description
          fields.append("description")
      if labels is not None:
          ds.labels = labels
          fields.append("labels")
      if default_table_expiration_ms is not None:
          ds.default_table_expiration_ms = default_table_expiration_ms
          fields.append("default_table_expiration_ms")
      if not fields:
          return _dataset_to_dict(ds)
      updated = client._client.update_dataset(ds, fields)
      return _dataset_to_dict(updated)


  def bq_delete_dataset(
      client: BQClient,
      dataset_id: str,
      delete_contents: bool = False,
  ) -> None:
      """Elimina un dataset.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.
          delete_contents: Si True, elimina todas las tablas del dataset.
              Si False y el dataset tiene tablas, falla.

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe.
          google.api_core.exceptions.BadRequest: Si tiene tablas y delete_contents=False.

      Example:
          >>> bq_delete_dataset(client, "temp_analytics", delete_contents=True)
      """
      ref = f"{client.project_id}.{dataset_id}"
      client._client.delete_dataset(ref, delete_contents=delete_contents)


  def _dataset_to_dict(ds) -> dict:
      """Convierte un objeto Dataset del SDK a dict plano."""
      return {
          "dataset_id": ds.dataset_id,
          "project": ds.project,
          "full_id": f"{ds.project}.{ds.dataset_id}",
          "location": ds.location,
          "description": ds.description or "",
          "labels": dict(ds.labels) if ds.labels else {},
          "created": ds.created.isoformat() if ds.created else None,
          "modified": ds.modified.isoformat() if ds.modified else None,
          "default_table_expiration_ms": ds.default_table_expiration_ms,
      }

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de BQClient"},{"name":"dataset_id","desc":"nombre del dataset dentro del proyecto (sin prefijo de proyecto)"},{"name":"location","desc":"ubicacion geografica del dataset: US, EU, us-central1, europe-west1, etc."},{"name":"description","desc":"descripcion opcional del dataset"},{"name":"labels","desc":"dict de labels key-value para categorizar el dataset"},{"name":"default_table_expiration_ms","desc":"tiempo de expiracion por defecto para tablas en milisegundos; 0 = sin expiracion"}],"output":"dict con dataset_id, project, full_id, location, description, labels, created, modified, default_table_expiration_ms"}


id: bq_create_routine_py_infra
name: bq_create_routine
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["bigquery","gcp","routine","udf","create","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/routines.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 9104fd1de945b4777fa0a26ec2fa8cf0775310b6df602c695e7a52fbaa121a4e
signature:
  def bq_create_routine(client: BQClient, dataset_id: str, routine_id: str, body: str, routine_type: str = 'SCALAR_FUNCTION', language: str = 'SQL', arguments: list[dict] | None = None, return_type: str = '', description: str = '') -> dict

description:
  Crea una routine (UDF scalar, tabla o stored procedure) en BigQuery. Soporta SQL, JavaScript y Python.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.routines import bq_create_routine

  client = bq_auth("my-project")

  # UDF escalar SQL
  fn = bq_create_routine(
      client,
      dataset_id="analytics",
      routine_id="double_value",
      body="x * 2",
      arguments=[{"name": "x", "data_type": "INT64"}],
      return_type="INT64",
      description="Duplica un entero",
  )
  print(fn["routine_id"], fn["routine_type"], fn["language"])
  # double_value SCALAR_FUNCTION SQL

  # Stored procedure SQL
  bq_create_routine(
      client,
      dataset_id="analytics",
      routine_id="refresh_summary",
      body="INSERT INTO summary SELECT * FROM raw WHERE date = CURRENT_DATE();",
      routine_type="PROCEDURE",
  )
  ```

notes:
  Lanza `google.api_core.exceptions.Conflict` (409) si la routine ya existe. Para actualizar una routine existente, eliminarla primero con `bq_delete_routine` y recrearla, o usar `client._client.update_routine()` directamente.

  Los `data_type` de los argumentos deben ser constantes de `bigquery.StandardSqlTypeNames`: `INT64`, `FLOAT64`, `STRING`, `BOOL`, `BYTES`, `DATE`, `DATETIME`, `TIMESTAMP`, `TIME`, `NUMERIC`, `BIGNUMERIC`, `JSON`, `ARRAY`, `STRUCT`.

  Las routines JavaScript permiten librerias externas via `imported_libraries` (no expuesto en este wrapper).

code:
  """Gestion de routines (UDFs y stored procedures) en Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  def bq_create_routine(
      client: BQClient,
      dataset_id: str,
      routine_id: str,
      body: str,
      routine_type: str = "SCALAR_FUNCTION",
      language: str = "SQL",
      arguments: list[dict] | None = None,
      return_type: str = "",
      description: str = "",
  ) -> dict:
      """Crea una routine (UDF o stored procedure) en BigQuery.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset donde crear la routine.
          routine_id: ID/nombre de la routine.
          body: Cuerpo de la routine (SQL, JavaScript o Python).
          routine_type: Tipo: "SCALAR_FUNCTION", "TABLE_VALUED_FUNCTION", "PROCEDURE".
          language: Lenguaje: "SQL", "JAVASCRIPT", "PYTHON".
          arguments: Lista de argumentos, cada uno: {"name": "x", "data_type": "STRING"}.
          return_type: Tipo de retorno para funciones (ej: "STRING", "INT64"). No aplica a procedures.
          description: Descripcion opcional.

      Returns:
          Dict con: routine_id, dataset_id, project, routine_type, language, body, created, modified.

      Raises:
          google.api_core.exceptions.Conflict: Si la routine ya existe.

      Example:
          >>> bq_create_routine(client, "analytics", "double_value",
          ...     body="x * 2",
          ...     arguments=[{"name": "x", "data_type": "INT64"}],
          ...     return_type="INT64")
      """
      ref = bigquery.RoutineReference.from_string(
          f"{client.project_id}.{dataset_id}.{routine_id}"
      )
      routine = bigquery.Routine(ref)
      routine.type_ = routine_type
      routine.language = language
      routine.body = body
      if description:
          routine.description = description
      if arguments:
          routine.arguments = [
              bigquery.RoutineArgument(
                  name=arg["name"],
                  data_type=bigquery.StandardSqlDataType(
                      type_kind=getattr(
                          bigquery.StandardSqlTypeNames, arg["data_type"]
                      )
                  ),
              )
              for arg in arguments
          ]
      if return_type:
          routine.return_type = bigquery.StandardSqlDataType(
              type_kind=getattr(bigquery.StandardSqlTypeNames, return_type)
          )
      created = client._client.create_routine(routine)
      return _routine_to_dict(created)


  def bq_list_routines(client: BQClient, dataset_id: str) -> list[dict]:
      """Lista routines de un dataset.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.

      Returns:
          Lista de dicts con: routine_id, dataset_id, project, routine_type, language.

      Example:
          >>> routines = bq_list_routines(client, "analytics")
          >>> for r in routines:
          ...     print(r["routine_id"], r["routine_type"], r["language"])
      """
      ref = f"{client.project_id}.{dataset_id}"
      return [
          {
              "routine_id": r.routine_id,
              "dataset_id": dataset_id,
              "project": client.project_id,
              "routine_type": r.type_,
              "language": r.language,
          }
          for r in client._client.list_routines(ref)
      ]


  def bq_delete_routine(
      client: BQClient,
      dataset_id: str,
      routine_id: str,
  ) -> None:
      """Elimina una routine.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.
          routine_id: ID de la routine a eliminar.

      Raises:
          google.api_core.exceptions.NotFound: Si la routine no existe.

      Example:
          >>> bq_delete_routine(client, "analytics", "double_value")
      """
      ref = f"{client.project_id}.{dataset_id}.{routine_id}"
      client._client.delete_routine(ref)


  def _routine_to_dict(routine) -> dict:
      """Convierte un objeto Routine del SDK a dict plano."""
      return {
          "routine_id": routine.routine_id,
          "dataset_id": routine.dataset_id,
          "project": routine.project,
          "routine_type": routine.type_,
          "language": routine.language,
          "body": routine.body,
          "description": routine.description or "",
          "created": routine.created.isoformat() if routine.created else None,
          "modified": routine.modified.isoformat() if routine.modified else None,
      }

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de BQClient"},{"name":"dataset_id","desc":"nombre del dataset donde se crea la routine"},{"name":"routine_id","desc":"nombre/identificador de la routine dentro del dataset"},{"name":"body","desc":"cuerpo de la routine: expresion SQL, bloque JavaScript o codigo Python segun el lenguaje"},{"name":"routine_type","desc":"tipo de routine: SCALAR_FUNCTION para UDFs escalares, TABLE_VALUED_FUNCTION para UDFs de tabla, PROCEDURE para stored procedures"},{"name":"language","desc":"lenguaje de implementacion: SQL, JAVASCRIPT o PYTHON"},{"name":"arguments","desc":"lista de argumentos, cada uno como dict con claves 'name' y 'data_type' (ej: INT64, STRING, FLOAT64)"},{"name":"return_type","desc":"tipo de dato que retorna la funcion (ej: INT64, STRING); ignorado para PROCEDURE"},{"name":"description","desc":"descripcion opcional de la routine"}],"output":"dict con routine_id, dataset_id, project, routine_type, language, body, description, created y modified (ISO 8601)"}


id: bq_create_table_py_infra
name: bq_create_table
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["bigquery","gcp","table","create","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/tables.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 292548adc00e0837a09fded0b2c83a355600b24b53e560696930f2ddb9476d8b
signature:
  def bq_create_table(client: BQClient, dataset_id: str, table_id: str, schema: list[dict], partitioning: dict | None = None, clustering: list[str] | None = None, description: str = '', labels: dict | None = None) -> dict

description:
  Crea una tabla en BigQuery con schema, particionamiento opcional y clustering. Usa client._client.create_table() del SDK oficial.

example:
  ```python
  from bigquery import bq_auth, bq_create_table

  client = bq_auth("mi-proyecto")

  tabla = bq_create_table(
      client,
      dataset_id="ventas_ds",
      table_id="transacciones",
      schema=[
          {"name": "id", "type": "INTEGER", "mode": "REQUIRED", "description": "ID unico"},
          {"name": "fecha", "type": "DATE", "mode": "NULLABLE", "description": "Fecha de la transaccion"},
          {"name": "monto", "type": "FLOAT", "mode": "NULLABLE", "description": "Monto en USD"},
          {"name": "pais", "type": "STRING", "mode": "NULLABLE", "description": "Codigo de pais"},
      ],
      partitioning={"type": "MONTH", "field": "fecha"},
      clustering=["pais"],
      description="Transacciones de ventas por mes",
      labels={"env": "prod", "team": "data"},
  )
  print(tabla["full_id"])
  ```

notes:
  El schema se convierte internamente de dicts a objetos `bigquery.SchemaField`. El particionamiento `TIME` soporta DAY, MONTH, YEAR y HOUR sobre columnas DATE/DATETIME/TIMESTAMP. Si `field` se omite en `partitioning`, BigQuery usa la pseudo-columna `_PARTITIONTIME`. El clustering requiere que las columnas existan en el schema y mejora rendimiento en filtros frecuentes.

code:
  """CRUD de tablas en Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  # ---------------------------------------------------------------------------
  # Helpers de serializacion
  # ---------------------------------------------------------------------------

  def _schema_field_to_dict(field: bigquery.SchemaField) -> dict:
      """Convierte un SchemaField a dict serializable."""
      result = {
          "name": field.name,
          "type": field.field_type,
          "mode": field.mode,
          "description": field.description or "",
      }
      if field.fields:
          result["fields"] = [_schema_field_to_dict(f) for f in field.fields]
      return result


  def _schema_to_dicts(schema: list) -> list[dict]:
      """Convierte una lista de SchemaField a lista de dicts."""
      return [_schema_field_to_dict(f) for f in schema]


  def _dict_to_schema_field(d: dict) -> bigquery.SchemaField:
      """Convierte un dict a SchemaField."""
      nested = [_dict_to_schema_field(f) for f in d.get("fields", [])]
      return bigquery.SchemaField(
          name=d["name"],
          field_type=d.get("type", "STRING"),
          mode=d.get("mode", "NULLABLE"),
          description=d.get("description", ""),
          fields=nested,
      )


  def _table_to_dict(table: bigquery.Table) -> dict:
      """Convierte un objeto Table del SDK a dict plano serializable."""
      partitioning = None
      if table.time_partitioning:
          partitioning = {
              "type": table.time_partitioning.type_,
              "field": table.time_partitioning.field or "",
          }
      elif table.range_partitioning:
          partitioning = {
              "type": "RANGE",
              "field": table.range_partitioning.field,
          }

      clustering = None
      if table.clustering_fields:
          clustering = list(table.clustering_fields)

      return {
          "table_id": table.table_id,
          "dataset_id": table.dataset_id,
          "project": table.project,
          "full_id": table.full_table_id or f"{table.project}.{table.dataset_id}.{table.table_id}",
          "schema": _schema_to_dicts(table.schema or []),
          "num_rows": table.num_rows,
          "num_bytes": table.num_bytes,
          "created": table.created.isoformat() if table.created else None,
          "modified": table.modified.isoformat() if table.modified else None,
          "type": table.table_type or "TABLE",
          "partitioning": partitioning,
          "clustering": clustering,
          "description": table.description or "",
          "labels": dict(table.labels or {}),
      }


  # ---------------------------------------------------------------------------
  # Funciones CRUD
  # ---------------------------------------------------------------------------

  def bq_create_table(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      schema: list[dict],
      partitioning: dict | None = None,
      clustering: list[str] | None = None,
      description: str = "",
      labels: dict | None = None,
  ) -> dict:
      """Crea una tabla en BigQuery con schema, particionamiento y clustering opcionales.

      Usa `client._client.create_table()` del SDK oficial.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset donde crear la tabla.
          table_id: ID (nombre) de la tabla a crear.
          schema: Lista de dicts con definicion de columnas. Cada dict:
              {"name": "col", "type": "STRING", "mode": "NULLABLE", "description": "..."}
              Tipos validos: STRING, INTEGER, FLOAT, BOOLEAN, BYTES, DATE, DATETIME,
              TIME, TIMESTAMP, RECORD, NUMERIC, BIGNUMERIC, JSON, GEOGRAPHY.
              Modos: NULLABLE, REQUIRED, REPEATED.
          partitioning: Dict de configuracion de particion o None. Ejemplo:
              {"type": "DAY", "field": "created_at"}
              Tipos: DAY, MONTH, YEAR, HOUR. Field vacio usa pseudo-columna _PARTITIONTIME.
          clustering: Lista de columnas para clustering (max 4) o None.
              Ejemplo: ["country", "status"]
          description: Descripcion de la tabla.
          labels: Etiquetas clave-valor para la tabla. Ejemplo: {"env": "prod"}.

      Returns:
          Dict con metadata de la tabla creada: table_id, dataset_id, project,
          full_id, schema, num_rows, num_bytes, created, modified, type,
          partitioning, clustering, description, labels.

      Raises:
          google.api_core.exceptions.Conflict: Si la tabla ya existe.
          google.api_core.exceptions.NotFound: Si el dataset no existe.
          google.api_core.exceptions.BadRequest: Si el schema es invalido.

      Example:
          >>> table = bq_create_table(client, "mi_dataset", "ventas", [
          ...     {"name": "id", "type": "INTEGER", "mode": "REQUIRED"},
          ...     {"name": "fecha", "type": "DATE", "mode": "NULLABLE"},
          ...     {"name": "monto", "type": "FLOAT", "mode": "NULLABLE"},
          ... ], partitioning={"type": "MONTH", "field": "fecha"},
          ...    clustering=["id"])
          >>> print(table["full_id"])
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      bq_schema = [_dict_to_schema_field(f) for f in schema]

      table = bigquery.Table(table_ref, schema=bq_schema)

      if partitioning:
          table.time_partitioning = bigquery.TimePartitioning(
              type_=partitioning.get("type", "DAY"),
              field=partitioning.get("field") or None,
          )

      if clustering:
          table.clustering_fields = clustering

      if description:
          table.description = description

      if labels:
          table.labels = labels

      created = client._client.create_table(table)
      return _table_to_dict(created)


  def bq_get_table(client: BQClient, dataset_id: str, table_id: str) -> dict:
      """Obtiene los metadatos completos de una tabla BigQuery.

      Usa `client._client.get_table()` del SDK oficial.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset que contiene la tabla.
          table_id: ID (nombre) de la tabla.

      Returns:
          Dict con: table_id, dataset_id, project, full_id, schema (lista de dicts),
          num_rows, num_bytes, created (ISO 8601), modified (ISO 8601), type
          (TABLE, VIEW, MATERIALIZED_VIEW, EXTERNAL), partitioning (dict o None),
          clustering (lista de strings o None), description, labels.

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.

      Example:
          >>> tabla = bq_get_table(client, "mi_dataset", "ventas")
          >>> print(tabla["num_rows"], tabla["schema"])
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      table = client._client.get_table(table_ref)
      return _table_to_dict(table)


  def bq_list_tables(client: BQClient, dataset_id: str) -> list[dict]:
      """Lista todas las tablas de un dataset BigQuery.

      Usa `client._client.list_tables()` del SDK oficial.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset a listar.

      Returns:
          Lista de dicts resumidos, uno por tabla. Cada dict contiene:
          table_id, full_id, type (TABLE, VIEW, MATERIALIZED_VIEW, EXTERNAL).

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe.

      Example:
          >>> tablas = bq_list_tables(client, "mi_dataset")
          >>> for t in tablas:
          ...     print(t["table_id"], t["type"])
      """
      dataset_ref = f"{client.project_id}.{dataset_id}"
      tables = client._client.list_tables(dataset_ref)
      return [
          {
              "table_id": t.table_id,
              "full_id": f"{t.project}.{t.dataset_id}.{t.table_id}",
              "type": t.table_type or "TABLE",
          }
          for t in tables
      ]


  def bq_update_table(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      schema: list[dict] | None = None,
      description: str | None = None,
      labels: dict | None = None,
  ) -> dict:
      """Actualiza metadatos de una tabla BigQuery.

      Usa `client._client.update_table()` del SDK oficial. Solo modifica los
      campos no-None. Para schema, BigQuery SOLO permite agregar columnas nuevas
      al final — no se pueden eliminar ni modificar columnas existentes.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset que contiene la tabla.
          table_id: ID (nombre) de la tabla a actualizar.
          schema: Lista de dicts con el schema completo nuevo (incluye columnas
              existentes + nuevas). Solo se permiten adiciones. None = sin cambios.
          description: Nueva descripcion de la tabla. None = sin cambios.
          labels: Nuevas etiquetas clave-valor. None = sin cambios.

      Returns:
          Dict con la metadata actualizada de la tabla (misma estructura que bq_get_table).

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.
          google.api_core.exceptions.BadRequest: Si se intenta eliminar columnas.

      Example:
          >>> tabla = bq_update_table(client, "mi_dataset", "ventas",
          ...     description="Tabla de ventas actualizada",
          ...     labels={"env": "prod", "team": "data"})
          >>> tabla = bq_update_table(client, "mi_dataset", "ventas",
          ...     schema=[
          ...         {"name": "id", "type": "INTEGER", "mode": "REQUIRED"},
          ...         {"name": "nueva_col", "type": "STRING", "mode": "NULLABLE"},
          ...     ])
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      table = client._client.get_table(table_ref)

      fields_to_update = []

      if schema is not None:
          table.schema = [_dict_to_schema_field(f) for f in schema]
          fields_to_update.append("schema")

      if description is not None:
          table.description = description
          fields_to_update.append("description")

      if labels is not None:
          table.labels = labels
          fields_to_update.append("labels")

      if not fields_to_update:
          return _table_to_dict(table)

      updated = client._client.update_table(table, fields_to_update)
      return _table_to_dict(updated)


  def bq_delete_table(client: BQClient, dataset_id: str, table_id: str) -> None:
      """Elimina permanentemente una tabla de BigQuery.

      Usa `client._client.delete_table()` del SDK oficial. IRREVERSIBLE — no hay
      papelera de reciclaje. Considerar exportar datos antes de eliminar.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset que contiene la tabla.
          table_id: ID (nombre) de la tabla a eliminar.

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.

      Example:
          >>> bq_delete_table(client, "mi_dataset", "tabla_temporal")
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      client._client.delete_table(table_ref)


  def bq_preview_rows(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      max_results: int = 10,
  ) -> dict:
      """Obtiene una muestra de filas de una tabla BigQuery sin ejecutar query.

      Usa `client._client.list_rows()` del SDK oficial — no genera costes de
      procesamiento de bytes (no es una query SQL). Ideal para vista previa rapida.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset que contiene la tabla.
          table_id: ID (nombre) de la tabla a previsualizar.
          max_results: Numero maximo de filas a retornar. Default: 10.

      Returns:
          Dict con:
          - columns: lista de strings con nombres de columnas
          - rows: lista de listas con valores de cada fila
          - total_rows: numero total de filas en la tabla (no en el preview)

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.

      Example:
          >>> preview = bq_preview_rows(client, "mi_dataset", "ventas", max_results=5)
          >>> print(preview["columns"])
          >>> for row in preview["rows"]:
          ...     print(row)
          >>> print(f"Total en tabla: {preview['total_rows']}")
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      table = client._client.get_table(table_ref)
      rows_iter = client._client.list_rows(table, max_results=max_results)

      columns = [f.name for f in rows_iter.schema]
      rows = [list(row.values()) for row in rows_iter]

      return {
          "columns": columns,
          "rows": rows,
          "total_rows": table.num_rows,
      }

params_schema:
  {"params":[{"name":"client","desc":"cliente autenticado BQClient obtenido con bq_auth"},{"name":"dataset_id","desc":"ID del dataset de BigQuery donde crear la tabla"},{"name":"table_id","desc":"nombre (ID) de la tabla a crear"},{"name":"schema","desc":"lista de dicts con definicion de columnas: [{name, type, mode, description}]. Tipos: STRING, INTEGER, FLOAT, BOOLEAN, DATE, TIMESTAMP, RECORD, etc."},{"name":"partitioning","desc":"dict opcional con tipo y campo de particion: {type: DAY|MONTH|YEAR|HOUR, field: nombre_col}. None = sin particion"},{"name":"clustering","desc":"lista de hasta 4 columnas para clustering (ordenacion fisica). None = sin clustering"},{"name":"description","desc":"descripcion legible de la tabla (vacio = sin descripcion)"},{"name":"labels","desc":"etiquetas clave-valor para la tabla, ej: {env: prod, team: data}"}],"output":"dict con metadata de la tabla creada: table_id, dataset_id, project, full_id, schema, num_rows, num_bytes, created, modified, type, partitioning, clustering, description, labels"}


id: bq_delete_dataset_py_infra
name: bq_delete_dataset
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def bq_delete_dataset(client: BQClient, dataset_id: str, delete_contents: bool = False) -> None
tags: ["bigquery","gcp","dataset","delete","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/datasets.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 5b136a42c62916e7b69261eb15bde708f91a7f827b8e29d8fd03120840c21cd6
description:
  Elimina un dataset de Google BigQuery. IRREVERSIBLE. Usa client._client.delete_dataset() del SDK oficial.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.datasets import bq_delete_dataset

  client = bq_auth("my-project")

  # Eliminar dataset vacio
  bq_delete_dataset(client, "temp_dataset")

  # Eliminar dataset con todas sus tablas
  bq_delete_dataset(client, "temp_analytics", delete_contents=True)
  ```

notes:
  IRREVERSIBLE. Todos los datos del dataset se pierden permanentemente.
  Lanza `google.api_core.exceptions.NotFound` (404) si el dataset no existe.
  Lanza `google.api_core.exceptions.BadRequest` (400) si el dataset contiene tablas y `delete_contents=False`.
  Por seguridad el valor por defecto de `delete_contents` es `False` — requiere confirmacion explicita para borrar contenido.

code:
  """CRUD de datasets en Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  def bq_create_dataset(
      client: BQClient,
      dataset_id: str,
      location: str = "US",
      description: str = "",
      labels: dict[str, str] | None = None,
      default_table_expiration_ms: int = 0,
  ) -> dict:
      """Crea un dataset en BigQuery.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset (solo el nombre, sin proyecto).
          location: Ubicacion geografica (US, EU, us-central1, etc.).
          description: Descripcion opcional.
          labels: Labels key-value opcionales.
          default_table_expiration_ms: Expiracion por defecto de tablas en ms. 0 = sin expiracion.

      Returns:
          Dict con: dataset_id, project, full_id, location, description, labels, created, modified,
          default_table_expiration_ms.

      Raises:
          google.api_core.exceptions.Conflict: Si el dataset ya existe (409).

      Example:
          >>> ds = bq_create_dataset(client, "analytics", location="EU", description="Data warehouse")
      """
      ref = f"{client.project_id}.{dataset_id}"
      ds = bigquery.Dataset(ref)
      ds.location = location
      if description:
          ds.description = description
      if labels:
          ds.labels = labels
      if default_table_expiration_ms > 0:
          ds.default_table_expiration_ms = default_table_expiration_ms
      created = client._client.create_dataset(ds)
      return _dataset_to_dict(created)


  def bq_get_dataset(client: BQClient, dataset_id: str) -> dict:
      """Obtiene los detalles de un dataset.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.

      Returns:
          Dict con: dataset_id, project, full_id, location, description, labels,
          created, modified, default_table_expiration_ms.

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe (404).

      Example:
          >>> ds = bq_get_dataset(client, "analytics")
          >>> print(ds["location"], ds["description"])
      """
      ref = f"{client.project_id}.{dataset_id}"
      ds = client._client.get_dataset(ref)
      return _dataset_to_dict(ds)


  def bq_list_datasets(client: BQClient) -> list[dict]:
      """Lista todos los datasets del proyecto.

      Args:
          client: Cliente autenticado.

      Returns:
          Lista de dicts con: dataset_id, project, full_id.

      Example:
          >>> datasets = bq_list_datasets(client)
          >>> for ds in datasets:
          ...     print(ds["dataset_id"], ds["full_id"])
      """
      return [
          {
              "dataset_id": ds.dataset_id,
              "project": ds.project,
              "full_id": f"{ds.project}.{ds.dataset_id}",
          }
          for ds in client._client.list_datasets()
      ]


  def bq_update_dataset(
      client: BQClient,
      dataset_id: str,
      description: str | None = None,
      labels: dict[str, str] | None = None,
      default_table_expiration_ms: int | None = None,
  ) -> dict:
      """Actualiza campos de un dataset.

      Solo se modifican los campos pasados (no-None).

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.
          description: Nueva descripcion (None = no cambiar).
          labels: Nuevos labels (None = no cambiar).
          default_table_expiration_ms: Nueva expiracion de tablas en ms (None = no cambiar).

      Returns:
          Dict con el dataset actualizado.

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe.

      Example:
          >>> bq_update_dataset(client, "analytics", description="Updated description")
      """
      ref = f"{client.project_id}.{dataset_id}"
      ds = client._client.get_dataset(ref)
      fields = []
      if description is not None:
          ds.description = description
          fields.append("description")
      if labels is not None:
          ds.labels = labels
          fields.append("labels")
      if default_table_expiration_ms is not None:
          ds.default_table_expiration_ms = default_table_expiration_ms
          fields.append("default_table_expiration_ms")
      if not fields:
          return _dataset_to_dict(ds)
      updated = client._client.update_dataset(ds, fields)
      return _dataset_to_dict(updated)


  def bq_delete_dataset(
      client: BQClient,
      dataset_id: str,
      delete_contents: bool = False,
  ) -> None:
      """Elimina un dataset.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.
          delete_contents: Si True, elimina todas las tablas del dataset.
              Si False y el dataset tiene tablas, falla.

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe.
          google.api_core.exceptions.BadRequest: Si tiene tablas y delete_contents=False.

      Example:
          >>> bq_delete_dataset(client, "temp_analytics", delete_contents=True)
      """
      ref = f"{client.project_id}.{dataset_id}"
      client._client.delete_dataset(ref, delete_contents=delete_contents)


  def _dataset_to_dict(ds) -> dict:
      """Convierte un objeto Dataset del SDK a dict plano."""
      return {
          "dataset_id": ds.dataset_id,
          "project": ds.project,
          "full_id": f"{ds.project}.{ds.dataset_id}",
          "location": ds.location,
          "description": ds.description or "",
          "labels": dict(ds.labels) if ds.labels else {},
          "created": ds.created.isoformat() if ds.created else None,
          "modified": ds.modified.isoformat() if ds.modified else None,
          "default_table_expiration_ms": ds.default_table_expiration_ms,
      }

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de BQClient"},{"name":"dataset_id","desc":"nombre del dataset a eliminar (sin prefijo de proyecto)"},{"name":"delete_contents","desc":"si True elimina todas las tablas y vistas del dataset antes de borrarlo; si False falla si el dataset contiene objetos"}],"output":"None"}


id: bq_delete_routine_py_infra
name: bq_delete_routine
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def bq_delete_routine(client: BQClient, dataset_id: str, routine_id: str) -> None
description: Elimina una routine de un dataset.
tags: ["bigquery","gcp","routine","udf","delete","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/routines.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 792f3cb4a47b1f74b78bfcbe3f21b5d4aa6968366b7ad50008e3939eebc78ab1
example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.routines import bq_delete_routine

  client = bq_auth("my-project")
  bq_delete_routine(client, "analytics", "double_value")
  # La routine queda eliminada permanentemente
  ```

notes:
  Lanza `google.api_core.exceptions.NotFound` (404) si la routine no existe. La operacion es permanente e irreversible.

  Para eliminar y recrear una routine actualizada, combinar con `bq_create_routine`:

  ```python
  bq_delete_routine(client, "analytics", "double_value")
  bq_create_routine(client, "analytics", "double_value", body="x * 3", ...)
  ```

code:
  """Gestion de routines (UDFs y stored procedures) en Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  def bq_create_routine(
      client: BQClient,
      dataset_id: str,
      routine_id: str,
      body: str,
      routine_type: str = "SCALAR_FUNCTION",
      language: str = "SQL",
      arguments: list[dict] | None = None,
      return_type: str = "",
      description: str = "",
  ) -> dict:
      """Crea una routine (UDF o stored procedure) en BigQuery.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset donde crear la routine.
          routine_id: ID/nombre de la routine.
          body: Cuerpo de la routine (SQL, JavaScript o Python).
          routine_type: Tipo: "SCALAR_FUNCTION", "TABLE_VALUED_FUNCTION", "PROCEDURE".
          language: Lenguaje: "SQL", "JAVASCRIPT", "PYTHON".
          arguments: Lista de argumentos, cada uno: {"name": "x", "data_type": "STRING"}.
          return_type: Tipo de retorno para funciones (ej: "STRING", "INT64"). No aplica a procedures.
          description: Descripcion opcional.

      Returns:
          Dict con: routine_id, dataset_id, project, routine_type, language, body, created, modified.

      Raises:
          google.api_core.exceptions.Conflict: Si la routine ya existe.

      Example:
          >>> bq_create_routine(client, "analytics", "double_value",
          ...     body="x * 2",
          ...     arguments=[{"name": "x", "data_type": "INT64"}],
          ...     return_type="INT64")
      """
      ref = bigquery.RoutineReference.from_string(
          f"{client.project_id}.{dataset_id}.{routine_id}"
      )
      routine = bigquery.Routine(ref)
      routine.type_ = routine_type
      routine.language = language
      routine.body = body
      if description:
          routine.description = description
      if arguments:
          routine.arguments = [
              bigquery.RoutineArgument(
                  name=arg["name"],
                  data_type=bigquery.StandardSqlDataType(
                      type_kind=getattr(
                          bigquery.StandardSqlTypeNames, arg["data_type"]
                      )
                  ),
              )
              for arg in arguments
          ]
      if return_type:
          routine.return_type = bigquery.StandardSqlDataType(
              type_kind=getattr(bigquery.StandardSqlTypeNames, return_type)
          )
      created = client._client.create_routine(routine)
      return _routine_to_dict(created)


  def bq_list_routines(client: BQClient, dataset_id: str) -> list[dict]:
      """Lista routines de un dataset.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.

      Returns:
          Lista de dicts con: routine_id, dataset_id, project, routine_type, language.

      Example:
          >>> routines = bq_list_routines(client, "analytics")
          >>> for r in routines:
          ...     print(r["routine_id"], r["routine_type"], r["language"])
      """
      ref = f"{client.project_id}.{dataset_id}"
      return [
          {
              "routine_id": r.routine_id,
              "dataset_id": dataset_id,
              "project": client.project_id,
              "routine_type": r.type_,
              "language": r.language,
          }
          for r in client._client.list_routines(ref)
      ]


  def bq_delete_routine(
      client: BQClient,
      dataset_id: str,
      routine_id: str,
  ) -> None:
      """Elimina una routine.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.
          routine_id: ID de la routine a eliminar.

      Raises:
          google.api_core.exceptions.NotFound: Si la routine no existe.

      Example:
          >>> bq_delete_routine(client, "analytics", "double_value")
      """
      ref = f"{client.project_id}.{dataset_id}.{routine_id}"
      client._client.delete_routine(ref)


  def _routine_to_dict(routine) -> dict:
      """Convierte un objeto Routine del SDK a dict plano."""
      return {
          "routine_id": routine.routine_id,
          "dataset_id": routine.dataset_id,
          "project": routine.project,
          "routine_type": routine.type_,
          "language": routine.language,
          "body": routine.body,
          "description": routine.description or "",
          "created": routine.created.isoformat() if routine.created else None,
          "modified": routine.modified.isoformat() if routine.modified else None,
      }

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de BQClient"},{"name":"dataset_id","desc":"nombre del dataset que contiene la routine"},{"name":"routine_id","desc":"nombre/identificador de la routine a eliminar"}],"output":"None; lanza NotFound si la routine no existe"}


id: bq_delete_table_py_infra
name: bq_delete_table
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def bq_delete_table(client: BQClient, dataset_id: str, table_id: str) -> None
tags: ["bigquery","gcp","table","delete","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/tables.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: cefa25bcfa8c7449c37faf585c532b75dd00b507694c81ad63e94457437b450b
description:
  Elimina permanentemente una tabla de BigQuery. IRREVERSIBLE. Usa client._client.delete_table() del SDK oficial.

example:
  ```python
  from bigquery import bq_auth, bq_delete_table

  client = bq_auth("mi-proyecto")

  # Eliminar tabla temporal
  bq_delete_table(client, "mi_dataset", "tabla_temporal_2024")

  # Verificar que no existe (capturar excepcion)
  from google.api_core.exceptions import NotFound
  try:
      bq_delete_table(client, "mi_dataset", "tabla_que_no_existe")
  except NotFound as e:
      print(f"No encontrada: {e}")
  ```

notes:
  La eliminacion es PERMANENTE — BigQuery no tiene papelera de reciclaje para tablas. Considerar exportar los datos a GCS antes de eliminar si hay posibilidad de necesitarlos. Si el dataset tiene `defaultTableExpirationMs` configurado, las tablas se pueden dejar expirar automaticamente en vez de eliminar manualmente. Requiere permiso `bigquery.tables.delete` sobre el dataset.

code:
  """CRUD de tablas en Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  # ---------------------------------------------------------------------------
  # Helpers de serializacion
  # ---------------------------------------------------------------------------

  def _schema_field_to_dict(field: bigquery.SchemaField) -> dict:
      """Convierte un SchemaField a dict serializable."""
      result = {
          "name": field.name,
          "type": field.field_type,
          "mode": field.mode,
          "description": field.description or "",
      }
      if field.fields:
          result["fields"] = [_schema_field_to_dict(f) for f in field.fields]
      return result


  def _schema_to_dicts(schema: list) -> list[dict]:
      """Convierte una lista de SchemaField a lista de dicts."""
      return [_schema_field_to_dict(f) for f in schema]


  def _dict_to_schema_field(d: dict) -> bigquery.SchemaField:
      """Convierte un dict a SchemaField."""
      nested = [_dict_to_schema_field(f) for f in d.get("fields", [])]
      return bigquery.SchemaField(
          name=d["name"],
          field_type=d.get("type", "STRING"),
          mode=d.get("mode", "NULLABLE"),
          description=d.get("description", ""),
          fields=nested,
      )


  def _table_to_dict(table: bigquery.Table) -> dict:
      """Convierte un objeto Table del SDK a dict plano serializable."""
      partitioning = None
      if table.time_partitioning:
          partitioning = {
              "type": table.time_partitioning.type_,
              "field": table.time_partitioning.field or "",
          }
      elif table.range_partitioning:
          partitioning = {
              "type": "RANGE",
              "field": table.range_partitioning.field,
          }

      clustering = None
      if table.clustering_fields:
          clustering = list(table.clustering_fields)

      return {
          "table_id": table.table_id,
          "dataset_id": table.dataset_id,
          "project": table.project,
          "full_id": table.full_table_id or f"{table.project}.{table.dataset_id}.{table.table_id}",
          "schema": _schema_to_dicts(table.schema or []),
          "num_rows": table.num_rows,
          "num_bytes": table.num_bytes,
          "created": table.created.isoformat() if table.created else None,
          "modified": table.modified.isoformat() if table.modified else None,
          "type": table.table_type or "TABLE",
          "partitioning": partitioning,
          "clustering": clustering,
          "description": table.description or "",
          "labels": dict(table.labels or {}),
      }


  # ---------------------------------------------------------------------------
  # Funciones CRUD
  # ---------------------------------------------------------------------------

  def bq_create_table(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      schema: list[dict],
      partitioning: dict | None = None,
      clustering: list[str] | None = None,
      description: str = "",
      labels: dict | None = None,
  ) -> dict:
      """Crea una tabla en BigQuery con schema, particionamiento y clustering opcionales.

      Usa `client._client.create_table()` del SDK oficial.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset donde crear la tabla.
          table_id: ID (nombre) de la tabla a crear.
          schema: Lista de dicts con definicion de columnas. Cada dict:
              {"name": "col", "type": "STRING", "mode": "NULLABLE", "description": "..."}
              Tipos validos: STRING, INTEGER, FLOAT, BOOLEAN, BYTES, DATE, DATETIME,
              TIME, TIMESTAMP, RECORD, NUMERIC, BIGNUMERIC, JSON, GEOGRAPHY.
              Modos: NULLABLE, REQUIRED, REPEATED.
          partitioning: Dict de configuracion de particion o None. Ejemplo:
              {"type": "DAY", "field": "created_at"}
              Tipos: DAY, MONTH, YEAR, HOUR. Field vacio usa pseudo-columna _PARTITIONTIME.
          clustering: Lista de columnas para clustering (max 4) o None.
              Ejemplo: ["country", "status"]
          description: Descripcion de la tabla.
          labels: Etiquetas clave-valor para la tabla. Ejemplo: {"env": "prod"}.

      Returns:
          Dict con metadata de la tabla creada: table_id, dataset_id, project,
          full_id, schema, num_rows, num_bytes, created, modified, type,
          partitioning, clustering, description, labels.

      Raises:
          google.api_core.exceptions.Conflict: Si la tabla ya existe.
          google.api_core.exceptions.NotFound: Si el dataset no existe.
          google.api_core.exceptions.BadRequest: Si el schema es invalido.

      Example:
          >>> table = bq_create_table(client, "mi_dataset", "ventas", [
          ...     {"name": "id", "type": "INTEGER", "mode": "REQUIRED"},
          ...     {"name": "fecha", "type": "DATE", "mode": "NULLABLE"},
          ...     {"name": "monto", "type": "FLOAT", "mode": "NULLABLE"},
          ... ], partitioning={"type": "MONTH", "field": "fecha"},
          ...    clustering=["id"])
          >>> print(table["full_id"])
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      bq_schema = [_dict_to_schema_field(f) for f in schema]

      table = bigquery.Table(table_ref, schema=bq_schema)

      if partitioning:
          table.time_partitioning = bigquery.TimePartitioning(
              type_=partitioning.get("type", "DAY"),
              field=partitioning.get("field") or None,
          )

      if clustering:
          table.clustering_fields = clustering

      if description:
          table.description = description

      if labels:
          table.labels = labels

      created = client._client.create_table(table)
      return _table_to_dict(created)


  def bq_get_table(client: BQClient, dataset_id: str, table_id: str) -> dict:
      """Obtiene los metadatos completos de una tabla BigQuery.

      Usa `client._client.get_table()` del SDK oficial.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset que contiene la tabla.
          table_id: ID (nombre) de la tabla.

      Returns:
          Dict con: table_id, dataset_id, project, full_id, schema (lista de dicts),
          num_rows, num_bytes, created (ISO 8601), modified (ISO 8601), type
          (TABLE, VIEW, MATERIALIZED_VIEW, EXTERNAL), partitioning (dict o None),
          clustering (lista de strings o None), description, labels.

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.

      Example:
          >>> tabla = bq_get_table(client, "mi_dataset", "ventas")
          >>> print(tabla["num_rows"], tabla["schema"])
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      table = client._client.get_table(table_ref)
      return _table_to_dict(table)


  def bq_list_tables(client: BQClient, dataset_id: str) -> list[dict]:
      """Lista todas las tablas de un dataset BigQuery.

      Usa `client._client.list_tables()` del SDK oficial.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset a listar.

      Returns:
          Lista de dicts resumidos, uno por tabla. Cada dict contiene:
          table_id, full_id, type (TABLE, VIEW, MATERIALIZED_VIEW, EXTERNAL).

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe.

      Example:
          >>> tablas = bq_list_tables(client, "mi_dataset")
          >>> for t in tablas:
          ...     print(t["table_id"], t["type"])
      """
      dataset_ref = f"{client.project_id}.{dataset_id}"
      tables = client._client.list_tables(dataset_ref)
      return [
          {
              "table_id": t.table_id,
              "full_id": f"{t.project}.{t.dataset_id}.{t.table_id}",
              "type": t.table_type or "TABLE",
          }
          for t in tables
      ]


  def bq_update_table(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      schema: list[dict] | None = None,
      description: str | None = None,
      labels: dict | None = None,
  ) -> dict:
      """Actualiza metadatos de una tabla BigQuery.

      Usa `client._client.update_table()` del SDK oficial. Solo modifica los
      campos no-None. Para schema, BigQuery SOLO permite agregar columnas nuevas
      al final — no se pueden eliminar ni modificar columnas existentes.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset que contiene la tabla.
          table_id: ID (nombre) de la tabla a actualizar.
          schema: Lista de dicts con el schema completo nuevo (incluye columnas
              existentes + nuevas). Solo se permiten adiciones. None = sin cambios.
          description: Nueva descripcion de la tabla. None = sin cambios.
          labels: Nuevas etiquetas clave-valor. None = sin cambios.

      Returns:
          Dict con la metadata actualizada de la tabla (misma estructura que bq_get_table).

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.
          google.api_core.exceptions.BadRequest: Si se intenta eliminar columnas.

      Example:
          >>> tabla = bq_update_table(client, "mi_dataset", "ventas",
          ...     description="Tabla de ventas actualizada",
          ...     labels={"env": "prod", "team": "data"})
          >>> tabla = bq_update_table(client, "mi_dataset", "ventas",
          ...     schema=[
          ...         {"name": "id", "type": "INTEGER", "mode": "REQUIRED"},
          ...         {"name": "nueva_col", "type": "STRING", "mode": "NULLABLE"},
          ...     ])
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      table = client._client.get_table(table_ref)

      fields_to_update = []

      if schema is not None:
          table.schema = [_dict_to_schema_field(f) for f in schema]
          fields_to_update.append("schema")

      if description is not None:
          table.description = description
          fields_to_update.append("description")

      if labels is not None:
          table.labels = labels
          fields_to_update.append("labels")

      if not fields_to_update:
          return _table_to_dict(table)

      updated = client._client.update_table(table, fields_to_update)
      return _table_to_dict(updated)


  def bq_delete_table(client: BQClient, dataset_id: str, table_id: str) -> None:
      """Elimina permanentemente una tabla de BigQuery.

      Usa `client._client.delete_table()` del SDK oficial. IRREVERSIBLE — no hay
      papelera de reciclaje. Considerar exportar datos antes de eliminar.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset que contiene la tabla.
          table_id: ID (nombre) de la tabla a eliminar.

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.

      Example:
          >>> bq_delete_table(client, "mi_dataset", "tabla_temporal")
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      client._client.delete_table(table_ref)


  def bq_preview_rows(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      max_results: int = 10,
  ) -> dict:
      """Obtiene una muestra de filas de una tabla BigQuery sin ejecutar query.

      Usa `client._client.list_rows()` del SDK oficial — no genera costes de
      procesamiento de bytes (no es una query SQL). Ideal para vista previa rapida.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset que contiene la tabla.
          table_id: ID (nombre) de la tabla a previsualizar.
          max_results: Numero maximo de filas a retornar. Default: 10.

      Returns:
          Dict con:
          - columns: lista de strings con nombres de columnas
          - rows: lista de listas con valores de cada fila
          - total_rows: numero total de filas en la tabla (no en el preview)

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.

      Example:
          >>> preview = bq_preview_rows(client, "mi_dataset", "ventas", max_results=5)
          >>> print(preview["columns"])
          >>> for row in preview["rows"]:
          ...     print(row)
          >>> print(f"Total en tabla: {preview['total_rows']}")
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      table = client._client.get_table(table_ref)
      rows_iter = client._client.list_rows(table, max_results=max_results)

      columns = [f.name for f in rows_iter.schema]
      rows = [list(row.values()) for row in rows_iter]

      return {
          "columns": columns,
          "rows": rows,
          "total_rows": table.num_rows,
      }

params_schema:
  {"params":[{"name":"client","desc":"cliente autenticado BQClient obtenido con bq_auth"},{"name":"dataset_id","desc":"ID del dataset que contiene la tabla"},{"name":"table_id","desc":"nombre (ID) de la tabla a eliminar"}],"output":"None. Lanza excepcion si la tabla no existe o no hay permisos"}


id: bq_export_to_gcs_py_infra
name: bq_export_to_gcs
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["bigquery","gcp","export","gcs","google-cloud","python","etl"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/queries.py
created_at: 2026-04-07T09:57:04Z
updated_at: 2026-04-07T09:57:04Z
props: []
emits: []
variant: []
content_hash: 7c7e546199d4f627841ee7ddc8b73187164817be09f3e17744f23c001c564b03
signature:
  def bq_export_to_gcs(client: BQClient, dataset_id: str, table_id: str, destination_uri: str, destination_format: str = 'CSV', compression: str = 'NONE') -> dict

description:
  Exporta una tabla BigQuery a Google Cloud Storage usando extract_table del SDK. Soporta CSV, JSON, Avro y Parquet con compresion opcional.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.queries import bq_export_to_gcs

  client = bq_auth("my-project")

  # Exportar como CSV comprimido con GZIP
  result = bq_export_to_gcs(
      client,
      "my_dataset", "users",
      "gs://my-bucket/exports/users_*.csv.gz",
      compression="GZIP",
  )
  print(f"Exportado: {result['destination_uri']} — job: {result['job_id']}")

  # Exportar como Parquet con compresion Snappy
  result = bq_export_to_gcs(
      client,
      "my_dataset", "events",
      "gs://my-bucket/exports/events_*.parquet",
      destination_format="PARQUET",
      compression="SNAPPY",
  )
  print(f"Status: {result['status']}")
  ```

notes:
  Para tablas grandes BigQuery genera multiples archivos automaticamente cuando se usa
  wildcard (`*`) en el URI. Sin wildcard falla si la tabla supera 1 GB.

  `SNAPPY` solo es valido para formatos binarios (PARQUET, AVRO). Usar `GZIP` para CSV
  y NEWLINE_DELIMITED_JSON.

  El ExtractJob es asincrono en BigQuery; `job.result()` bloquea hasta completar.

code:
  """Queries y operaciones de datos para Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  def bq_query(
      client: BQClient,
      sql: str,
      params: list[dict] | None = None,
      dry_run: bool = False,
  ) -> dict:
      """Ejecuta una query SQL en BigQuery con soporte para parametros y dry-run.

      Si dry_run=True, estima el costo sin ejecutar la query. Si params no es
      None, los convierte a ScalarQueryParameter usando la sintaxis @nombre en SQL.

      Args:
          client: Cliente autenticado de BigQuery.
          sql: Query SQL a ejecutar. Usar @nombre para referencias a parametros.
          params: Lista de parametros. Cada elemento: {"name": "x", "type": "STRING", "value": "hello"}.
                  Tipos soportados: STRING, INT64, FLOAT64, BOOL, DATE, TIMESTAMP.
          dry_run: Si True, retorna estimacion de bytes sin ejecutar.

      Returns:
          Si dry_run=True: {"total_bytes_processed": N, "total_bytes_billed": N}.
          Si dry_run=False: {"columns": [...], "rows": [[...], ...],
                             "total_rows": N, "bytes_processed": N, "cache_hit": bool}.

      Raises:
          google.api_core.exceptions.GoogleAPICallError: Si la query falla.
          ValueError: Si un tipo de parametro no es soportado.

      Example:
          >>> result = bq_query(client, "SELECT COUNT(*) as n FROM `project.dataset.table`")
          >>> print(result["rows"])
          >>> # Con parametros:
          >>> result = bq_query(client, "SELECT * FROM t WHERE status = @s",
          ...     params=[{"name": "s", "type": "STRING", "value": "active"}])
      """
      job_config = bigquery.QueryJobConfig()

      if params:
          query_params = []
          for p in params:
              query_params.append(
                  bigquery.ScalarQueryParameter(p["name"], p["type"], p["value"])
              )
          job_config.query_parameters = query_params

      if dry_run:
          job_config.dry_run = True
          job_config.use_query_cache = False
          job = client._client.query(sql, job_config=job_config)
          return {
              "total_bytes_processed": job.total_bytes_processed,
              "total_bytes_billed": job.total_bytes_billed,
          }

      job = client._client.query(sql, job_config=job_config)
      result = job.result()

      columns = [field.name for field in result.schema]
      rows = [list(row.values()) for row in result]

      return {
          "columns": columns,
          "rows": rows,
          "total_rows": result.total_rows,
          "bytes_processed": job.total_bytes_processed,
          "cache_hit": job.cache_hit,
      }


  def bq_insert_rows(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      rows: list[dict],
  ) -> dict:
      """Inserta filas en una tabla BigQuery usando el streaming insert.

      Usa la API de streaming (insert_rows_json) para insercion en tiempo real.
      Los errores de filas individuales se retornan en el campo "errors" sin
      lanzar excepcion — revisar siempre ese campo.

      Args:
          client: Cliente autenticado de BigQuery.
          dataset_id: ID del dataset de destino.
          table_id: ID de la tabla de destino.
          rows: Lista de dicts con los datos a insertar. Cada dict debe tener
                claves que coincidan con las columnas de la tabla.

      Returns:
          {"inserted": N, "errors": [...]} donde errors es la lista de errores
          de streaming insert retornada por la API (vacia si todo OK).

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.
          google.api_core.exceptions.GoogleAPICallError: Si la API falla.

      Example:
          >>> result = bq_insert_rows(client, "my_dataset", "my_table", [
          ...     {"id": 1, "name": "Alice", "created_at": "2024-01-01"},
          ...     {"id": 2, "name": "Bob",   "created_at": "2024-01-02"},
          ... ])
          >>> if result["errors"]:
          ...     print("Errores:", result["errors"])
      """
      table_ref = client._client.dataset(dataset_id).table(table_id)
      errors = client._client.insert_rows_json(table_ref, rows)
      return {
          "inserted": len(rows) - len(errors),
          "errors": errors,
      }


  def bq_load_from_gcs(
      client: BQClient,
      uri: str | list[str],
      dataset_id: str,
      table_id: str,
      source_format: str = "CSV",
      write_disposition: str = "WRITE_APPEND",
      autodetect: bool = True,
      skip_leading_rows: int = 0,
  ) -> dict:
      """Carga datos desde Google Cloud Storage a una tabla BigQuery.

      Configura y ejecuta un LoadJob desde uno o varios URIs de GCS. Espera
      la finalizacion del job con job.result().

      Args:
          client: Cliente autenticado de BigQuery.
          uri: URI de GCS. Puede ser un string ("gs://bucket/file.csv") o una
               lista de strings para multiples archivos.
          dataset_id: ID del dataset de destino.
          table_id: ID de la tabla de destino.
          source_format: Formato del archivo: "CSV", "NEWLINE_DELIMITED_JSON",
                         "AVRO", "PARQUET", "ORC". Default: "CSV".
          write_disposition: Comportamiento si la tabla existe: "WRITE_APPEND",
                             "WRITE_TRUNCATE", "WRITE_EMPTY". Default: "WRITE_APPEND".
          autodetect: Si True, detecta el schema automaticamente. Default: True.
          skip_leading_rows: Numero de filas a saltar al inicio (cabeceras CSV).

      Returns:
          {"job_id": str, "rows_loaded": N, "status": "DONE"|"FAILED"}.

      Raises:
          google.api_core.exceptions.GoogleAPICallError: Si el job falla.
          google.cloud.exceptions.NotFound: Si el bucket o dataset no existe.

      Example:
          >>> result = bq_load_from_gcs(
          ...     client, "gs://my-bucket/data/*.csv",
          ...     "my_dataset", "my_table",
          ...     skip_leading_rows=1,
          ... )
          >>> print(f"Cargadas {result['rows_loaded']} filas, job: {result['job_id']}")
      """
      format_map = {
          "CSV": bigquery.SourceFormat.CSV,
          "NEWLINE_DELIMITED_JSON": bigquery.SourceFormat.NEWLINE_DELIMITED_JSON,
          "AVRO": bigquery.SourceFormat.AVRO,
          "PARQUET": bigquery.SourceFormat.PARQUET,
          "ORC": bigquery.SourceFormat.ORC,
      }
      disposition_map = {
          "WRITE_APPEND": bigquery.WriteDisposition.WRITE_APPEND,
          "WRITE_TRUNCATE": bigquery.WriteDisposition.WRITE_TRUNCATE,
          "WRITE_EMPTY": bigquery.WriteDisposition.WRITE_EMPTY,
      }

      job_config = bigquery.LoadJobConfig(
          source_format=format_map.get(source_format, bigquery.SourceFormat.CSV),
          write_disposition=disposition_map.get(source_format, bigquery.WriteDisposition.WRITE_APPEND),
          autodetect=autodetect,
          skip_leading_rows=skip_leading_rows,
      )
      job_config.write_disposition = disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND)

      table_ref = client._client.dataset(dataset_id).table(table_id)
      uris = uri if isinstance(uri, list) else [uri]

      job = client._client.load_table_from_uri(uris, table_ref, job_config=job_config)
      result = job.result()

      return {
          "job_id": job.job_id,
          "rows_loaded": result.output_rows,
          "status": "DONE" if job.state == "DONE" and not job.errors else "FAILED",
      }


  def bq_load_from_file(
      client: BQClient,
      file_path: str,
      dataset_id: str,
      table_id: str,
      source_format: str = "CSV",
      write_disposition: str = "WRITE_APPEND",
      autodetect: bool = True,
      skip_leading_rows: int = 0,
  ) -> dict:
      """Carga datos desde un archivo local a una tabla BigQuery.

      Abre el archivo y usa load_table_from_file del SDK. Equivalente a
      bq_load_from_gcs pero para archivos en disco local.

      Args:
          client: Cliente autenticado de BigQuery.
          file_path: Ruta absoluta o relativa al archivo local a cargar.
          dataset_id: ID del dataset de destino.
          table_id: ID de la tabla de destino.
          source_format: Formato del archivo: "CSV", "NEWLINE_DELIMITED_JSON",
                         "AVRO", "PARQUET", "ORC". Default: "CSV".
          write_disposition: Comportamiento si la tabla existe: "WRITE_APPEND",
                             "WRITE_TRUNCATE", "WRITE_EMPTY". Default: "WRITE_APPEND".
          autodetect: Si True, detecta el schema automaticamente. Default: True.
          skip_leading_rows: Numero de filas a saltar al inicio (cabeceras CSV).

      Returns:
          {"job_id": str, "rows_loaded": N, "status": "DONE"|"FAILED"}.

      Raises:
          FileNotFoundError: Si el archivo local no existe.
          google.api_core.exceptions.GoogleAPICallError: Si el job falla.

      Example:
          >>> result = bq_load_from_file(
          ...     client, "/tmp/data.csv",
          ...     "my_dataset", "my_table",
          ...     skip_leading_rows=1,
          ... )
          >>> print(f"Cargadas {result['rows_loaded']} filas")
      """
      format_map = {
          "CSV": bigquery.SourceFormat.CSV,
          "NEWLINE_DELIMITED_JSON": bigquery.SourceFormat.NEWLINE_DELIMITED_JSON,
          "AVRO": bigquery.SourceFormat.AVRO,
          "PARQUET": bigquery.SourceFormat.PARQUET,
          "ORC": bigquery.SourceFormat.ORC,
      }
      disposition_map = {
          "WRITE_APPEND": bigquery.WriteDisposition.WRITE_APPEND,
          "WRITE_TRUNCATE": bigquery.WriteDisposition.WRITE_TRUNCATE,
          "WRITE_EMPTY": bigquery.WriteDisposition.WRITE_EMPTY,
      }

      job_config = bigquery.LoadJobConfig(
          source_format=format_map.get(source_format, bigquery.SourceFormat.CSV),
          write_disposition=disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND),
          autodetect=autodetect,
          skip_leading_rows=skip_leading_rows,
      )

      table_ref = client._client.dataset(dataset_id).table(table_id)

      with open(file_path, "rb") as f:
          job = client._client.load_table_from_file(f, table_ref, job_config=job_config)

      result = job.result()

      return {
          "job_id": job.job_id,
          "rows_loaded": result.output_rows,
          "status": "DONE" if job.state == "DONE" and not job.errors else "FAILED",
      }


  def bq_export_to_gcs(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      destination_uri: str,
      destination_format: str = "CSV",
      compression: str = "NONE",
  ) -> dict:
      """Exporta una tabla BigQuery a Google Cloud Storage.

      Usa extract_table del SDK para crear un ExtractJob. Espera la finalizacion
      del job con job.result().

      Args:
          client: Cliente autenticado de BigQuery.
          dataset_id: ID del dataset de origen.
          table_id: ID de la tabla de origen.
          destination_uri: URI de destino en GCS. Para multiples archivos usar
                           wildcard: "gs://bucket/prefix_*.csv".
          destination_format: Formato de exportacion: "CSV", "NEWLINE_DELIMITED_JSON",
                              "AVRO", "PARQUET". Default: "CSV".
          compression: Compresion: "NONE", "GZIP", "SNAPPY", "DEFLATE". Default: "NONE".

      Returns:
          {"job_id": str, "destination_uri": str, "status": "DONE"|"FAILED"}.

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.
          google.api_core.exceptions.GoogleAPICallError: Si el job falla.

      Example:
          >>> result = bq_export_to_gcs(
          ...     client, "my_dataset", "my_table",
          ...     "gs://my-bucket/export/data_*.csv",
          ...     compression="GZIP",
          ... )
          >>> print(f"Exportado a {result['destination_uri']}, job: {result['job_id']}")
      """
      format_map = {
          "CSV": bigquery.DestinationFormat.CSV,
          "NEWLINE_DELIMITED_JSON": bigquery.DestinationFormat.NEWLINE_DELIMITED_JSON,
          "AVRO": bigquery.DestinationFormat.AVRO,
          "PARQUET": bigquery.DestinationFormat.PARQUET,
      }
      compression_map = {
          "NONE": bigquery.Compression.NONE,
          "GZIP": bigquery.Compression.GZIP,
          "SNAPPY": bigquery.Compression.SNAPPY,
          "DEFLATE": bigquery.Compression.DEFLATE,
      }

      job_config = bigquery.ExtractJobConfig(
          destination_format=format_map.get(destination_format, bigquery.DestinationFormat.CSV),
          compression=compression_map.get(compression, bigquery.Compression.NONE),
      )

      table_ref = client._client.dataset(dataset_id).table(table_id)
      job = client._client.extract_table(table_ref, destination_uri, job_config=job_config)
      job.result()

      return {
          "job_id": job.job_id,
          "destination_uri": destination_uri,
          "status": "DONE" if job.state == "DONE" and not job.errors else "FAILED",
      }


  def bq_copy_table(
      client: BQClient,
      source_dataset: str,
      source_table: str,
      dest_dataset: str,
      dest_table: str,
      write_disposition: str = "WRITE_EMPTY",
  ) -> dict:
      """Copia una tabla BigQuery a otro dataset o tabla dentro del mismo proyecto.

      Usa copy_table del SDK para crear un CopyJob. Espera la finalizacion
      del job con job.result(). No copia datos entre proyectos distintos.

      Args:
          client: Cliente autenticado de BigQuery.
          source_dataset: ID del dataset de origen.
          source_table: ID de la tabla de origen.
          dest_dataset: ID del dataset de destino.
          dest_table: ID de la tabla de destino.
          write_disposition: Comportamiento si la tabla destino existe: "WRITE_EMPTY"
                             (falla si existe), "WRITE_APPEND", "WRITE_TRUNCATE".
                             Default: "WRITE_EMPTY".

      Returns:
          {"job_id": str, "status": "DONE"|"FAILED"}.

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla de origen no existe.
          google.api_core.exceptions.Conflict: Si destino existe y write_disposition es WRITE_EMPTY.
          google.api_core.exceptions.GoogleAPICallError: Si el job falla.

      Example:
          >>> result = bq_copy_table(
          ...     client,
          ...     "production", "users",
          ...     "staging", "users_backup",
          ...     write_disposition="WRITE_TRUNCATE",
          ... )
          >>> print(f"Copia completada: {result['status']}, job: {result['job_id']}")
      """
      disposition_map = {
          "WRITE_APPEND": bigquery.WriteDisposition.WRITE_APPEND,
          "WRITE_TRUNCATE": bigquery.WriteDisposition.WRITE_TRUNCATE,
          "WRITE_EMPTY": bigquery.WriteDisposition.WRITE_EMPTY,
      }

      job_config = bigquery.CopyJobConfig(
          write_disposition=disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_EMPTY),
      )

      source_ref = client._client.dataset(source_dataset).table(source_table)
      dest_ref = client._client.dataset(dest_dataset).table(dest_table)

      job = client._client.copy_table(source_ref, dest_ref, job_config=job_config)
      job.result()

      return {
          "job_id": job.job_id,
          "status": "DONE" if job.state == "DONE" and not job.errors else "FAILED",
      }

params_schema:
  {"params":[{"name":"client","desc":"cliente BQClient autenticado contra el proyecto GCP"},{"name":"dataset_id","desc":"ID del dataset de origen en BigQuery"},{"name":"table_id","desc":"ID de la tabla de origen en BigQuery"},{"name":"destination_uri","desc":"URI de destino en GCS; para tablas grandes usar wildcard: gs://bucket/prefix_*.csv"},{"name":"destination_format","desc":"formato de exportacion: CSV, NEWLINE_DELIMITED_JSON, AVRO, PARQUET"},{"name":"compression","desc":"algoritmo de compresion: NONE, GZIP, SNAPPY (solo Parquet/Avro), DEFLATE"}],"output":"dict con {job_id: ID del ExtractJob, destination_uri: URI de destino, status: DONE o FAILED}"}


id: bq_get_dataset_py_infra
name: bq_get_dataset
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def bq_get_dataset(client: BQClient, dataset_id: str) -> dict
tags: ["bigquery","gcp","dataset","get","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/datasets.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 498405a2d5433e392553fc1707983cead425d83cb2335e73e23b71960a098a7e
description:
  Obtiene los detalles completos de un dataset de Google BigQuery. Usa client._client.get_dataset() del SDK oficial.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.datasets import bq_get_dataset

  client = bq_auth("my-project")
  ds = bq_get_dataset(client, "analytics")
  print(ds["location"], ds["description"])
  print(ds["created"])  # ISO 8601: "2024-01-15T10:30:00+00:00"
  ```

notes:
  Lanza `google.api_core.exceptions.NotFound` (404) si el dataset no existe.
  Los campos `created` y `modified` son strings ISO 8601 con timezone UTC, o `None` si el SDK no los retorna.
  `labels` es un dict vacio `{}` si el dataset no tiene labels.

code:
  """CRUD de datasets en Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  def bq_create_dataset(
      client: BQClient,
      dataset_id: str,
      location: str = "US",
      description: str = "",
      labels: dict[str, str] | None = None,
      default_table_expiration_ms: int = 0,
  ) -> dict:
      """Crea un dataset en BigQuery.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset (solo el nombre, sin proyecto).
          location: Ubicacion geografica (US, EU, us-central1, etc.).
          description: Descripcion opcional.
          labels: Labels key-value opcionales.
          default_table_expiration_ms: Expiracion por defecto de tablas en ms. 0 = sin expiracion.

      Returns:
          Dict con: dataset_id, project, full_id, location, description, labels, created, modified,
          default_table_expiration_ms.

      Raises:
          google.api_core.exceptions.Conflict: Si el dataset ya existe (409).

      Example:
          >>> ds = bq_create_dataset(client, "analytics", location="EU", description="Data warehouse")
      """
      ref = f"{client.project_id}.{dataset_id}"
      ds = bigquery.Dataset(ref)
      ds.location = location
      if description:
          ds.description = description
      if labels:
          ds.labels = labels
      if default_table_expiration_ms > 0:
          ds.default_table_expiration_ms = default_table_expiration_ms
      created = client._client.create_dataset(ds)
      return _dataset_to_dict(created)


  def bq_get_dataset(client: BQClient, dataset_id: str) -> dict:
      """Obtiene los detalles de un dataset.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.

      Returns:
          Dict con: dataset_id, project, full_id, location, description, labels,
          created, modified, default_table_expiration_ms.

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe (404).

      Example:
          >>> ds = bq_get_dataset(client, "analytics")
          >>> print(ds["location"], ds["description"])
      """
      ref = f"{client.project_id}.{dataset_id}"
      ds = client._client.get_dataset(ref)
      return _dataset_to_dict(ds)


  def bq_list_datasets(client: BQClient) -> list[dict]:
      """Lista todos los datasets del proyecto.

      Args:
          client: Cliente autenticado.

      Returns:
          Lista de dicts con: dataset_id, project, full_id.

      Example:
          >>> datasets = bq_list_datasets(client)
          >>> for ds in datasets:
          ...     print(ds["dataset_id"], ds["full_id"])
      """
      return [
          {
              "dataset_id": ds.dataset_id,
              "project": ds.project,
              "full_id": f"{ds.project}.{ds.dataset_id}",
          }
          for ds in client._client.list_datasets()
      ]


  def bq_update_dataset(
      client: BQClient,
      dataset_id: str,
      description: str | None = None,
      labels: dict[str, str] | None = None,
      default_table_expiration_ms: int | None = None,
  ) -> dict:
      """Actualiza campos de un dataset.

      Solo se modifican los campos pasados (no-None).

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.
          description: Nueva descripcion (None = no cambiar).
          labels: Nuevos labels (None = no cambiar).
          default_table_expiration_ms: Nueva expiracion de tablas en ms (None = no cambiar).

      Returns:
          Dict con el dataset actualizado.

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe.

      Example:
          >>> bq_update_dataset(client, "analytics", description="Updated description")
      """
      ref = f"{client.project_id}.{dataset_id}"
      ds = client._client.get_dataset(ref)
      fields = []
      if description is not None:
          ds.description = description
          fields.append("description")
      if labels is not None:
          ds.labels = labels
          fields.append("labels")
      if default_table_expiration_ms is not None:
          ds.default_table_expiration_ms = default_table_expiration_ms
          fields.append("default_table_expiration_ms")
      if not fields:
          return _dataset_to_dict(ds)
      updated = client._client.update_dataset(ds, fields)
      return _dataset_to_dict(updated)


  def bq_delete_dataset(
      client: BQClient,
      dataset_id: str,
      delete_contents: bool = False,
  ) -> None:
      """Elimina un dataset.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.
          delete_contents: Si True, elimina todas las tablas del dataset.
              Si False y el dataset tiene tablas, falla.

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe.
          google.api_core.exceptions.BadRequest: Si tiene tablas y delete_contents=False.

      Example:
          >>> bq_delete_dataset(client, "temp_analytics", delete_contents=True)
      """
      ref = f"{client.project_id}.{dataset_id}"
      client._client.delete_dataset(ref, delete_contents=delete_contents)


  def _dataset_to_dict(ds) -> dict:
      """Convierte un objeto Dataset del SDK a dict plano."""
      return {
          "dataset_id": ds.dataset_id,
          "project": ds.project,
          "full_id": f"{ds.project}.{ds.dataset_id}",
          "location": ds.location,
          "description": ds.description or "",
          "labels": dict(ds.labels) if ds.labels else {},
          "created": ds.created.isoformat() if ds.created else None,
          "modified": ds.modified.isoformat() if ds.modified else None,
          "default_table_expiration_ms": ds.default_table_expiration_ms,
      }

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de BQClient"},{"name":"dataset_id","desc":"nombre del dataset a consultar (sin prefijo de proyecto)"}],"output":"dict con dataset_id, project, full_id, location, description, labels, created, modified, default_table_expiration_ms"}


id: bq_get_job_py_infra
name: bq_get_job
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def bq_get_job(client: BQClient, job_id: str) -> dict
tags: ["bigquery","gcp","job","get","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/jobs.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 360fa2100be752cd2df3f7cdc7884aec6cf7289f28805e08ac4d531af82d93a5
description:
  Obtiene detalles completos de un job por su ID incluyendo estado, bytes procesados y errores. Incluye campos adicionales respecto a bq_list_jobs: destination_table, query y lista de errores.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.jobs import bq_get_job

  client = bq_auth("my-project")

  job = bq_get_job(client, "job_abc123")
  print(job["state"], job["bytes_processed"])

  # Inspeccionar query SQL del job
  if job.get("query"):
      print("SQL:", job["query"][:200])

  # Ver errores detallados
  if job["error"]:
      print("Error principal:", job["error"])
      for err in job.get("errors", []):
          print(" -", err)
  ```

notes:
  Lanza `google.api_core.exceptions.NotFound` si el job_id no existe en el proyecto.

  A diferencia de `bq_list_jobs`, este metodo retorna campos adicionales cuando estan disponibles:
  - `destination_table`: tabla destino (solo en query/load jobs con destino explicito)
  - `query`: texto SQL del job (solo en query jobs)
  - `errors`: lista completa de errores (cada uno como dict con `reason`, `message`, `location`)

  Los campos `created`, `started` y `ended` se serializan como strings ISO 8601.

code:
  """Gestion de jobs en Google BigQuery."""

  from .client import BQClient


  def bq_list_jobs(
      client: BQClient,
      state_filter: str = "",
      max_results: int = 50,
      all_users: bool = False,
  ) -> list[dict]:
      """Lista jobs del proyecto con filtro opcional por estado.

      Args:
          client: Cliente autenticado.
          state_filter: Filtro: "running", "pending", "done". Vacio = todos.
          max_results: Numero maximo de jobs a retornar.
          all_users: Si True, lista jobs de todos los usuarios (requiere permisos).

      Returns:
          Lista de dicts con: job_id, job_type, state, created, started, ended,
          user_email, bytes_processed, error.

      Example:
          >>> jobs = bq_list_jobs(client, state_filter="running")
          >>> for j in jobs:
          ...     print(j["job_id"], j["state"], j["job_type"])
      """
      kwargs = {"max_results": max_results, "all_users": all_users}
      if state_filter:
          kwargs["state_filter"] = state_filter
      return [_job_to_dict(job) for job in client._client.list_jobs(**kwargs)]


  def bq_get_job(client: BQClient, job_id: str) -> dict:
      """Obtiene detalles de un job por su ID.

      Args:
          client: Cliente autenticado.
          job_id: ID del job.

      Returns:
          Dict con: job_id, job_type, state, created, started, ended,
          user_email, bytes_processed, destination_table, query, error, errors.

      Raises:
          google.api_core.exceptions.NotFound: Si el job no existe.

      Example:
          >>> job = bq_get_job(client, "job_abc123")
          >>> print(job["state"], job["bytes_processed"])
          >>> if job["error"]:
          ...     print("Error:", job["error"])
      """
      job = client._client.get_job(job_id)
      result = _job_to_dict(job)
      # Agregar detalles extra disponibles en get pero no en list
      if hasattr(job, 'destination') and job.destination:
          result["destination_table"] = str(job.destination)
      if hasattr(job, 'query') and job.query:
          result["query"] = job.query
      if job.errors:
          result["errors"] = [dict(e) for e in job.errors]
      return result


  def bq_cancel_job(client: BQClient, job_id: str) -> dict:
      """Cancela un job en ejecucion.

      Args:
          client: Cliente autenticado.
          job_id: ID del job a cancelar.

      Returns:
          Dict con: job_id, state (tras cancelacion).

      Raises:
          google.api_core.exceptions.NotFound: Si el job no existe.

      Example:
          >>> result = bq_cancel_job(client, "job_abc123")
          >>> print(result["state"])  # "DONE" (cancelled)
      """
      job = client._client.cancel_job(job_id)
      return {"job_id": job.job_id, "state": job.state}


  def _job_to_dict(job) -> dict:
      """Convierte un objeto Job del SDK a dict plano."""
      result = {
          "job_id": job.job_id,
          "job_type": job.job_type,
          "state": job.state,
          "created": job.created.isoformat() if job.created else None,
          "started": job.started.isoformat() if job.started else None,
          "ended": job.ended.isoformat() if job.ended else None,
          "user_email": job.user_email,
          "error": str(job.error_result) if job.error_result else None,
      }
      # bytes_processed solo disponible en query jobs
      try:
          result["bytes_processed"] = job.total_bytes_processed
      except AttributeError:
          result["bytes_processed"] = None
      return result

params_schema:
  {"params":[{"name":"client","desc":"cliente autenticado de BigQuery obtenido con bq_auth"},{"name":"job_id","desc":"ID del job a consultar (formato: 'proyecto:region.job_id' o solo 'job_id')"}],"output":"dict con campos: job_id, job_type, state, created, started, ended, user_email, bytes_processed, error, y opcionalmente destination_table, query, errors"}


id: bq_get_table_py_infra
name: bq_get_table
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def bq_get_table(client: BQClient, dataset_id: str, table_id: str) -> dict
tags: ["bigquery","gcp","table","get","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/tables.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 7844bb5e0683e00b7dbe376948bb8231a6967b4a1088720a4708bbce58f5f972
description:
  Obtiene los metadatos completos de una tabla BigQuery incluyendo schema, estadisticas y configuracion. Usa client._client.get_table() del SDK oficial.

example:
  ```python
  from bigquery import bq_auth, bq_get_table

  client = bq_auth("mi-proyecto")

  tabla = bq_get_table(client, "ventas_ds", "transacciones")
  print(tabla["full_id"])          # mi-proyecto.ventas_ds.transacciones
  print(tabla["num_rows"])         # filas totales
  print(tabla["num_bytes"])        # bytes almacenados
  for col in tabla["schema"]:
      print(col["name"], col["type"], col["mode"])
  ```

notes:
  `num_rows` y `num_bytes` son estadisticas actualizadas por BigQuery periodicamente (pueden tener un pequeno retraso). El campo `type` puede ser TABLE, VIEW, MATERIALIZED_VIEW o EXTERNAL. `partitioning` es None si la tabla no tiene particionamiento. `created` y `modified` estan en formato ISO 8601.

code:
  """CRUD de tablas en Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  # ---------------------------------------------------------------------------
  # Helpers de serializacion
  # ---------------------------------------------------------------------------

  def _schema_field_to_dict(field: bigquery.SchemaField) -> dict:
      """Convierte un SchemaField a dict serializable."""
      result = {
          "name": field.name,
          "type": field.field_type,
          "mode": field.mode,
          "description": field.description or "",
      }
      if field.fields:
          result["fields"] = [_schema_field_to_dict(f) for f in field.fields]
      return result


  def _schema_to_dicts(schema: list) -> list[dict]:
      """Convierte una lista de SchemaField a lista de dicts."""
      return [_schema_field_to_dict(f) for f in schema]


  def _dict_to_schema_field(d: dict) -> bigquery.SchemaField:
      """Convierte un dict a SchemaField."""
      nested = [_dict_to_schema_field(f) for f in d.get("fields", [])]
      return bigquery.SchemaField(
          name=d["name"],
          field_type=d.get("type", "STRING"),
          mode=d.get("mode", "NULLABLE"),
          description=d.get("description", ""),
          fields=nested,
      )


  def _table_to_dict(table: bigquery.Table) -> dict:
      """Convierte un objeto Table del SDK a dict plano serializable."""
      partitioning = None
      if table.time_partitioning:
          partitioning = {
              "type": table.time_partitioning.type_,
              "field": table.time_partitioning.field or "",
          }
      elif table.range_partitioning:
          partitioning = {
              "type": "RANGE",
              "field": table.range_partitioning.field,
          }

      clustering = None
      if table.clustering_fields:
          clustering = list(table.clustering_fields)

      return {
          "table_id": table.table_id,
          "dataset_id": table.dataset_id,
          "project": table.project,
          "full_id": table.full_table_id or f"{table.project}.{table.dataset_id}.{table.table_id}",
          "schema": _schema_to_dicts(table.schema or []),
          "num_rows": table.num_rows,
          "num_bytes": table.num_bytes,
          "created": table.created.isoformat() if table.created else None,
          "modified": table.modified.isoformat() if table.modified else None,
          "type": table.table_type or "TABLE",
          "partitioning": partitioning,
          "clustering": clustering,
          "description": table.description or "",
          "labels": dict(table.labels or {}),
      }


  # ---------------------------------------------------------------------------
  # Funciones CRUD
  # ---------------------------------------------------------------------------

  def bq_create_table(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      schema: list[dict],
      partitioning: dict | None = None,
      clustering: list[str] | None = None,
      description: str = "",
      labels: dict | None = None,
  ) -> dict:
      """Crea una tabla en BigQuery con schema, particionamiento y clustering opcionales.

      Usa `client._client.create_table()` del SDK oficial.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset donde crear la tabla.
          table_id: ID (nombre) de la tabla a crear.
          schema: Lista de dicts con definicion de columnas. Cada dict:
              {"name": "col", "type": "STRING", "mode": "NULLABLE", "description": "..."}
              Tipos validos: STRING, INTEGER, FLOAT, BOOLEAN, BYTES, DATE, DATETIME,
              TIME, TIMESTAMP, RECORD, NUMERIC, BIGNUMERIC, JSON, GEOGRAPHY.
              Modos: NULLABLE, REQUIRED, REPEATED.
          partitioning: Dict de configuracion de particion o None. Ejemplo:
              {"type": "DAY", "field": "created_at"}
              Tipos: DAY, MONTH, YEAR, HOUR. Field vacio usa pseudo-columna _PARTITIONTIME.
          clustering: Lista de columnas para clustering (max 4) o None.
              Ejemplo: ["country", "status"]
          description: Descripcion de la tabla.
          labels: Etiquetas clave-valor para la tabla. Ejemplo: {"env": "prod"}.

      Returns:
          Dict con metadata de la tabla creada: table_id, dataset_id, project,
          full_id, schema, num_rows, num_bytes, created, modified, type,
          partitioning, clustering, description, labels.

      Raises:
          google.api_core.exceptions.Conflict: Si la tabla ya existe.
          google.api_core.exceptions.NotFound: Si el dataset no existe.
          google.api_core.exceptions.BadRequest: Si el schema es invalido.

      Example:
          >>> table = bq_create_table(client, "mi_dataset", "ventas", [
          ...     {"name": "id", "type": "INTEGER", "mode": "REQUIRED"},
          ...     {"name": "fecha", "type": "DATE", "mode": "NULLABLE"},
          ...     {"name": "monto", "type": "FLOAT", "mode": "NULLABLE"},
          ... ], partitioning={"type": "MONTH", "field": "fecha"},
          ...    clustering=["id"])
          >>> print(table["full_id"])
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      bq_schema = [_dict_to_schema_field(f) for f in schema]

      table = bigquery.Table(table_ref, schema=bq_schema)

      if partitioning:
          table.time_partitioning = bigquery.TimePartitioning(
              type_=partitioning.get("type", "DAY"),
              field=partitioning.get("field") or None,
          )

      if clustering:
          table.clustering_fields = clustering

      if description:
          table.description = description

      if labels:
          table.labels = labels

      created = client._client.create_table(table)
      return _table_to_dict(created)


  def bq_get_table(client: BQClient, dataset_id: str, table_id: str) -> dict:
      """Obtiene los metadatos completos de una tabla BigQuery.

      Usa `client._client.get_table()` del SDK oficial.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset que contiene la tabla.
          table_id: ID (nombre) de la tabla.

      Returns:
          Dict con: table_id, dataset_id, project, full_id, schema (lista de dicts),
          num_rows, num_bytes, created (ISO 8601), modified (ISO 8601), type
          (TABLE, VIEW, MATERIALIZED_VIEW, EXTERNAL), partitioning (dict o None),
          clustering (lista de strings o None), description, labels.

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.

      Example:
          >>> tabla = bq_get_table(client, "mi_dataset", "ventas")
          >>> print(tabla["num_rows"], tabla["schema"])
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      table = client._client.get_table(table_ref)
      return _table_to_dict(table)


  def bq_list_tables(client: BQClient, dataset_id: str) -> list[dict]:
      """Lista todas las tablas de un dataset BigQuery.

      Usa `client._client.list_tables()` del SDK oficial.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset a listar.

      Returns:
          Lista de dicts resumidos, uno por tabla. Cada dict contiene:
          table_id, full_id, type (TABLE, VIEW, MATERIALIZED_VIEW, EXTERNAL).

      Raises:
          google.api_core.exceptions.NotFound: Si el dataset no existe.

      Example:
          >>> tablas = bq_list_tables(client, "mi_dataset")
          >>> for t in tablas:
          ...     print(t["table_id"], t["type"])
      """
      dataset_ref = f"{client.project_id}.{dataset_id}"
      tables = client._client.list_tables(dataset_ref)
      return [
          {
              "table_id": t.table_id,
              "full_id": f"{t.project}.{t.dataset_id}.{t.table_id}",
              "type": t.table_type or "TABLE",
          }
          for t in tables
      ]


  def bq_update_table(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      schema: list[dict] | None = None,
      description: str | None = None,
      labels: dict | None = None,
  ) -> dict:
      """Actualiza metadatos de una tabla BigQuery.

      Usa `client._client.update_table()` del SDK oficial. Solo modifica los
      campos no-None. Para schema, BigQuery SOLO permite agregar columnas nuevas
      al final — no se pueden eliminar ni modificar columnas existentes.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset que contiene la tabla.
          table_id: ID (nombre) de la tabla a actualizar.
          schema: Lista de dicts con el schema completo nuevo (incluye columnas
              existentes + nuevas). Solo se permiten adiciones. None = sin cambios.
          description: Nueva descripcion de la tabla. None = sin cambios.
          labels: Nuevas etiquetas clave-valor. None = sin cambios.

      Returns:
          Dict con la metadata actualizada de la tabla (misma estructura que bq_get_table).

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.
          google.api_core.exceptions.BadRequest: Si se intenta eliminar columnas.

      Example:
          >>> tabla = bq_update_table(client, "mi_dataset", "ventas",
          ...     description="Tabla de ventas actualizada",
          ...     labels={"env": "prod", "team": "data"})
          >>> tabla = bq_update_table(client, "mi_dataset", "ventas",
          ...     schema=[
          ...         {"name": "id", "type": "INTEGER", "mode": "REQUIRED"},
          ...         {"name": "nueva_col", "type": "STRING", "mode": "NULLABLE"},
          ...     ])
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      table = client._client.get_table(table_ref)

      fields_to_update = []

      if schema is not None:
          table.schema = [_dict_to_schema_field(f) for f in schema]
          fields_to_update.append("schema")

      if description is not None:
          table.description = description
          fields_to_update.append("description")

      if labels is not None:
          table.labels = labels
          fields_to_update.append("labels")

      if not fields_to_update:
          return _table_to_dict(table)

      updated = client._client.update_table(table, fields_to_update)
      return _table_to_dict(updated)


  def bq_delete_table(client: BQClient, dataset_id: str, table_id: str) -> None:
      """Elimina permanentemente una tabla de BigQuery.

      Usa `client._client.delete_table()` del SDK oficial. IRREVERSIBLE — no hay
      papelera de reciclaje. Considerar exportar datos antes de eliminar.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset que contiene la tabla.
          table_id: ID (nombre) de la tabla a eliminar.

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.

      Example:
          >>> bq_delete_table(client, "mi_dataset", "tabla_temporal")
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      client._client.delete_table(table_ref)


  def bq_preview_rows(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      max_results: int = 10,
  ) -> dict:
      """Obtiene una muestra de filas de una tabla BigQuery sin ejecutar query.

      Usa `client._client.list_rows()` del SDK oficial — no genera costes de
      procesamiento de bytes (no es una query SQL). Ideal para vista previa rapida.

      Args:
          client: Cliente autenticado BQClient.
          dataset_id: ID del dataset que contiene la tabla.
          table_id: ID (nombre) de la tabla a previsualizar.
          max_results: Numero maximo de filas a retornar. Default: 10.

      Returns:
          Dict con:
          - columns: lista de strings con nombres de columnas
          - rows: lista de listas con valores de cada fila
          - total_rows: numero total de filas en la tabla (no en el preview)

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.

      Example:
          >>> preview = bq_preview_rows(client, "mi_dataset", "ventas", max_results=5)
          >>> print(preview["columns"])
          >>> for row in preview["rows"]:
          ...     print(row)
          >>> print(f"Total en tabla: {preview['total_rows']}")
      """
      table_ref = f"{client.project_id}.{dataset_id}.{table_id}"
      table = client._client.get_table(table_ref)
      rows_iter = client._client.list_rows(table, max_results=max_results)

      columns = [f.name for f in rows_iter.schema]
      rows = [list(row.values()) for row in rows_iter]

      return {
          "columns": columns,
          "rows": rows,
          "total_rows": table.num_rows,
      }

params_schema:
  {"params":[{"name":"client","desc":"cliente autenticado BQClient obtenido con bq_auth"},{"name":"dataset_id","desc":"ID del dataset que contiene la tabla"},{"name":"table_id","desc":"nombre (ID) de la tabla a consultar"}],"output":"dict con metadata completa: table_id, dataset_id, project, full_id, schema (lista de dicts), num_rows, num_bytes, created (ISO 8601), modified (ISO 8601), type, partitioning, clustering, description, labels"}


id: bq_insert_rows_py_infra
name: bq_insert_rows
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def bq_insert_rows(client: BQClient, dataset_id: str, table_id: str, rows: list[dict]) -> dict
tags: ["bigquery","gcp","insert","streaming","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/queries.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: cf5865fb090f39e36956cb39f98b7ab4e4d6e3caeeeddf338a60ef6381a242a0
description:
  Inserta filas en una tabla BigQuery usando streaming insert (insert_rows_json). Retorna el conteo de filas insertadas y errores por fila.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.queries import bq_insert_rows

  client = bq_auth("my-project")

  result = bq_insert_rows(client, "my_dataset", "events", [
      {"event_id": "abc1", "user_id": 42, "ts": "2024-01-01T12:00:00Z", "action": "click"},
      {"event_id": "abc2", "user_id": 99, "ts": "2024-01-01T12:01:00Z", "action": "view"},
  ])

  print(f"Insertadas: {result['inserted']}")
  if result["errors"]:
      print("Errores:", result["errors"])
  ```

notes:
  El streaming insert tiene disponibilidad casi inmediata pero no es transaccional.
  Las filas pueden aparecer duplicadas si el job se reintenta — BigQuery no garantiza
  exactamente-una-vez con insert_rows_json.

  `errors` es la lista retornada directamente por la API. Cada elemento es un dict con
  `index` (posicion de la fila fallida) y `errors` (lista de mensajes de error).

  Para cargas masivas o garantias ACID, preferir `bq_load_from_gcs` o `bq_load_from_file`.

code:
  """Queries y operaciones de datos para Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  def bq_query(
      client: BQClient,
      sql: str,
      params: list[dict] | None = None,
      dry_run: bool = False,
  ) -> dict:
      """Ejecuta una query SQL en BigQuery con soporte para parametros y dry-run.

      Si dry_run=True, estima el costo sin ejecutar la query. Si params no es
      None, los convierte a ScalarQueryParameter usando la sintaxis @nombre en SQL.

      Args:
          client: Cliente autenticado de BigQuery.
          sql: Query SQL a ejecutar. Usar @nombre para referencias a parametros.
          params: Lista de parametros. Cada elemento: {"name": "x", "type": "STRING", "value": "hello"}.
                  Tipos soportados: STRING, INT64, FLOAT64, BOOL, DATE, TIMESTAMP.
          dry_run: Si True, retorna estimacion de bytes sin ejecutar.

      Returns:
          Si dry_run=True: {"total_bytes_processed": N, "total_bytes_billed": N}.
          Si dry_run=False: {"columns": [...], "rows": [[...], ...],
                             "total_rows": N, "bytes_processed": N, "cache_hit": bool}.

      Raises:
          google.api_core.exceptions.GoogleAPICallError: Si la query falla.
          ValueError: Si un tipo de parametro no es soportado.

      Example:
          >>> result = bq_query(client, "SELECT COUNT(*) as n FROM `project.dataset.table`")
          >>> print(result["rows"])
          >>> # Con parametros:
          >>> result = bq_query(client, "SELECT * FROM t WHERE status = @s",
          ...     params=[{"name": "s", "type": "STRING", "value": "active"}])
      """
      job_config = bigquery.QueryJobConfig()

      if params:
          query_params = []
          for p in params:
              query_params.append(
                  bigquery.ScalarQueryParameter(p["name"], p["type"], p["value"])
              )
          job_config.query_parameters = query_params

      if dry_run:
          job_config.dry_run = True
          job_config.use_query_cache = False
          job = client._client.query(sql, job_config=job_config)
          return {
              "total_bytes_processed": job.total_bytes_processed,
              "total_bytes_billed": job.total_bytes_billed,
          }

      job = client._client.query(sql, job_config=job_config)
      result = job.result()

      columns = [field.name for field in result.schema]
      rows = [list(row.values()) for row in result]

      return {
          "columns": columns,
          "rows": rows,
          "total_rows": result.total_rows,
          "bytes_processed": job.total_bytes_processed,
          "cache_hit": job.cache_hit,
      }


  def bq_insert_rows(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      rows: list[dict],
  ) -> dict:
      """Inserta filas en una tabla BigQuery usando el streaming insert.

      Usa la API de streaming (insert_rows_json) para insercion en tiempo real.
      Los errores de filas individuales se retornan en el campo "errors" sin
      lanzar excepcion — revisar siempre ese campo.

      Args:
          client: Cliente autenticado de BigQuery.
          dataset_id: ID del dataset de destino.
          table_id: ID de la tabla de destino.
          rows: Lista de dicts con los datos a insertar. Cada dict debe tener
                claves que coincidan con las columnas de la tabla.

      Returns:
          {"inserted": N, "errors": [...]} donde errors es la lista de errores
          de streaming insert retornada por la API (vacia si todo OK).

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.
          google.api_core.exceptions.GoogleAPICallError: Si la API falla.

      Example:
          >>> result = bq_insert_rows(client, "my_dataset", "my_table", [
          ...     {"id": 1, "name": "Alice", "created_at": "2024-01-01"},
          ...     {"id": 2, "name": "Bob",   "created_at": "2024-01-02"},
          ... ])
          >>> if result["errors"]:
          ...     print("Errores:", result["errors"])
      """
      table_ref = client._client.dataset(dataset_id).table(table_id)
      errors = client._client.insert_rows_json(table_ref, rows)
      return {
          "inserted": len(rows) - len(errors),
          "errors": errors,
      }


  def bq_load_from_gcs(
      client: BQClient,
      uri: str | list[str],
      dataset_id: str,
      table_id: str,
      source_format: str = "CSV",
      write_disposition: str = "WRITE_APPEND",
      autodetect: bool = True,
      skip_leading_rows: int = 0,
  ) -> dict:
      """Carga datos desde Google Cloud Storage a una tabla BigQuery.

      Configura y ejecuta un LoadJob desde uno o varios URIs de GCS. Espera
      la finalizacion del job con job.result().

      Args:
          client: Cliente autenticado de BigQuery.
          uri: URI de GCS. Puede ser un string ("gs://bucket/file.csv") o una
               lista de strings para multiples archivos.
          dataset_id: ID del dataset de destino.
          table_id: ID de la tabla de destino.
          source_format: Formato del archivo: "CSV", "NEWLINE_DELIMITED_JSON",
                         "AVRO", "PARQUET", "ORC". Default: "CSV".
          write_disposition: Comportamiento si la tabla existe: "WRITE_APPEND",
                             "WRITE_TRUNCATE", "WRITE_EMPTY". Default: "WRITE_APPEND".
          autodetect: Si True, detecta el schema automaticamente. Default: True.
          skip_leading_rows: Numero de filas a saltar al inicio (cabeceras CSV).

      Returns:
          {"job_id": str, "rows_loaded": N, "status": "DONE"|"FAILED"}.

      Raises:
          google.api_core.exceptions.GoogleAPICallError: Si el job falla.
          google.cloud.exceptions.NotFound: Si el bucket o dataset no existe.

      Example:
          >>> result = bq_load_from_gcs(
          ...     client, "gs://my-bucket/data/*.csv",
          ...     "my_dataset", "my_table",
          ...     skip_leading_rows=1,
          ... )
          >>> print(f"Cargadas {result['rows_loaded']} filas, job: {result['job_id']}")
      """
      format_map = {
          "CSV": bigquery.SourceFormat.CSV,
          "NEWLINE_DELIMITED_JSON": bigquery.SourceFormat.NEWLINE_DELIMITED_JSON,
          "AVRO": bigquery.SourceFormat.AVRO,
          "PARQUET": bigquery.SourceFormat.PARQUET,
          "ORC": bigquery.SourceFormat.ORC,
      }
      disposition_map = {
          "WRITE_APPEND": bigquery.WriteDisposition.WRITE_APPEND,
          "WRITE_TRUNCATE": bigquery.WriteDisposition.WRITE_TRUNCATE,
          "WRITE_EMPTY": bigquery.WriteDisposition.WRITE_EMPTY,
      }

      job_config = bigquery.LoadJobConfig(
          source_format=format_map.get(source_format, bigquery.SourceFormat.CSV),
          write_disposition=disposition_map.get(source_format, bigquery.WriteDisposition.WRITE_APPEND),
          autodetect=autodetect,
          skip_leading_rows=skip_leading_rows,
      )
      job_config.write_disposition = disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND)

      table_ref = client._client.dataset(dataset_id).table(table_id)
      uris = uri if isinstance(uri, list) else [uri]

      job = client._client.load_table_from_uri(uris, table_ref, job_config=job_config)
      result = job.result()

      return {
          "job_id": job.job_id,
          "rows_loaded": result.output_rows,
          "status": "DONE" if job.state == "DONE" and not job.errors else "FAILED",
      }


  def bq_load_from_file(
      client: BQClient,
      file_path: str,
      dataset_id: str,
      table_id: str,
      source_format: str = "CSV",
      write_disposition: str = "WRITE_APPEND",
      autodetect: bool = True,
      skip_leading_rows: int = 0,
  ) -> dict:
      """Carga datos desde un archivo local a una tabla BigQuery.

      Abre el archivo y usa load_table_from_file del SDK. Equivalente a
      bq_load_from_gcs pero para archivos en disco local.

      Args:
          client: Cliente autenticado de BigQuery.
          file_path: Ruta absoluta o relativa al archivo local a cargar.
          dataset_id: ID del dataset de destino.
          table_id: ID de la tabla de destino.
          source_format: Formato del archivo: "CSV", "NEWLINE_DELIMITED_JSON",
                         "AVRO", "PARQUET", "ORC". Default: "CSV".
          write_disposition: Comportamiento si la tabla existe: "WRITE_APPEND",
                             "WRITE_TRUNCATE", "WRITE_EMPTY". Default: "WRITE_APPEND".
          autodetect: Si True, detecta el schema automaticamente. Default: True.
          skip_leading_rows: Numero de filas a saltar al inicio (cabeceras CSV).

      Returns:
          {"job_id": str, "rows_loaded": N, "status": "DONE"|"FAILED"}.

      Raises:
          FileNotFoundError: Si el archivo local no existe.
          google.api_core.exceptions.GoogleAPICallError: Si el job falla.

      Example:
          >>> result = bq_load_from_file(
          ...     client, "/tmp/data.csv",
          ...     "my_dataset", "my_table",
          ...     skip_leading_rows=1,
          ... )
          >>> print(f"Cargadas {result['rows_loaded']} filas")
      """
      format_map = {
          "CSV": bigquery.SourceFormat.CSV,
          "NEWLINE_DELIMITED_JSON": bigquery.SourceFormat.NEWLINE_DELIMITED_JSON,
          "AVRO": bigquery.SourceFormat.AVRO,
          "PARQUET": bigquery.SourceFormat.PARQUET,
          "ORC": bigquery.SourceFormat.ORC,
      }
      disposition_map = {
          "WRITE_APPEND": bigquery.WriteDisposition.WRITE_APPEND,
          "WRITE_TRUNCATE": bigquery.WriteDisposition.WRITE_TRUNCATE,
          "WRITE_EMPTY": bigquery.WriteDisposition.WRITE_EMPTY,
      }

      job_config = bigquery.LoadJobConfig(
          source_format=format_map.get(source_format, bigquery.SourceFormat.CSV),
          write_disposition=disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND),
          autodetect=autodetect,
          skip_leading_rows=skip_leading_rows,
      )

      table_ref = client._client.dataset(dataset_id).table(table_id)

      with open(file_path, "rb") as f:
          job = client._client.load_table_from_file(f, table_ref, job_config=job_config)

      result = job.result()

      return {
          "job_id": job.job_id,
          "rows_loaded": result.output_rows,
          "status": "DONE" if job.state == "DONE" and not job.errors else "FAILED",
      }


  def bq_export_to_gcs(
      client: BQClient,
      dataset_id: str,
      table_id: str,
      destination_uri: str,
      destination_format: str = "CSV",
      compression: str = "NONE",
  ) -> dict:
      """Exporta una tabla BigQuery a Google Cloud Storage.

      Usa extract_table del SDK para crear un ExtractJob. Espera la finalizacion
      del job con job.result().

      Args:
          client: Cliente autenticado de BigQuery.
          dataset_id: ID del dataset de origen.
          table_id: ID de la tabla de origen.
          destination_uri: URI de destino en GCS. Para multiples archivos usar
                           wildcard: "gs://bucket/prefix_*.csv".
          destination_format: Formato de exportacion: "CSV", "NEWLINE_DELIMITED_JSON",
                              "AVRO", "PARQUET". Default: "CSV".
          compression: Compresion: "NONE", "GZIP", "SNAPPY", "DEFLATE". Default: "NONE".

      Returns:
          {"job_id": str, "destination_uri": str, "status": "DONE"|"FAILED"}.

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla no existe.
          google.api_core.exceptions.GoogleAPICallError: Si el job falla.

      Example:
          >>> result = bq_export_to_gcs(
          ...     client, "my_dataset", "my_table",
          ...     "gs://my-bucket/export/data_*.csv",
          ...     compression="GZIP",
          ... )
          >>> print(f"Exportado a {result['destination_uri']}, job: {result['job_id']}")
      """
      format_map = {
          "CSV": bigquery.DestinationFormat.CSV,
          "NEWLINE_DELIMITED_JSON": bigquery.DestinationFormat.NEWLINE_DELIMITED_JSON,
          "AVRO": bigquery.DestinationFormat.AVRO,
          "PARQUET": bigquery.DestinationFormat.PARQUET,
      }
      compression_map = {
          "NONE": bigquery.Compression.NONE,
          "GZIP": bigquery.Compression.GZIP,
          "SNAPPY": bigquery.Compression.SNAPPY,
          "DEFLATE": bigquery.Compression.DEFLATE,
      }

      job_config = bigquery.ExtractJobConfig(
          destination_format=format_map.get(destination_format, bigquery.DestinationFormat.CSV),
          compression=compression_map.get(compression, bigquery.Compression.NONE),
      )

      table_ref = client._client.dataset(dataset_id).table(table_id)
      job = client._client.extract_table(table_ref, destination_uri, job_config=job_config)
      job.result()

      return {
          "job_id": job.job_id,
          "destination_uri": destination_uri,
          "status": "DONE" if job.state == "DONE" and not job.errors else "FAILED",
      }


  def bq_copy_table(
      client: BQClient,
      source_dataset: str,
      source_table: str,
      dest_dataset: str,
      dest_table: str,
      write_disposition: str = "WRITE_EMPTY",
  ) -> dict:
      """Copia una tabla BigQuery a otro dataset o tabla dentro del mismo proyecto.

      Usa copy_table del SDK para crear un CopyJob. Espera la finalizacion
      del job con job.result(). No copia datos entre proyectos distintos.

      Args:
          client: Cliente autenticado de BigQuery.
          source_dataset: ID del dataset de origen.
          source_table: ID de la tabla de origen.
          dest_dataset: ID del dataset de destino.
          dest_table: ID de la tabla de destino.
          write_disposition: Comportamiento si la tabla destino existe: "WRITE_EMPTY"
                             (falla si existe), "WRITE_APPEND", "WRITE_TRUNCATE".
                             Default: "WRITE_EMPTY".

      Returns:
          {"job_id": str, "status": "DONE"|"FAILED"}.

      Raises:
          google.api_core.exceptions.NotFound: Si la tabla de origen no existe.
          google.api_core.exceptions.Conflict: Si destino existe y write_disposition es WRITE_EMPTY.
          google.api_core.exceptions.GoogleAPICallError: Si el job falla.

      Example:
          >>> result = bq_copy_table(
          ...     client,
          ...     "production", "users",
          ...     "staging", "users_backup",
          ...     write_disposition="WRITE_TRUNCATE",
          ... )
          >>> print(f"Copia completada: {result['status']}, job: {result['job_id']}")
      """
      disposition_map = {
          "WRITE_APPEND": bigquery.WriteDisposition.WRITE_APPEND,
          "WRITE_TRUNCATE": bigquery.WriteDisposition.WRITE_TRUNCATE,
          "WRITE_EMPTY": bigquery.WriteDisposition.WRITE_EMPTY,
      }

      job_config = bigquery.CopyJobConfig(
          write_disposition=disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_EMPTY),
      )

      source_ref = client._client.dataset(source_dataset).table(source_table)
      dest_ref = client._client.dataset(dest_dataset).table(dest_table)

      job = client._client.copy_table(source_ref, dest_ref, job_config=job_config)
      job.result()

      return {
          "job_id": job.job_id,
          "status": "DONE" if job.state == "DONE" and not job.errors else "FAILED",
      }

params_schema:
  {"params":[{"name":"client","desc":"cliente BQClient autenticado contra el proyecto GCP"},{"name":"dataset_id","desc":"ID del dataset de destino en BigQuery"},{"name":"table_id","desc":"ID de la tabla de destino en BigQuery"},{"name":"rows","desc":"lista de dicts con los datos a insertar; las claves deben coincidir con las columnas de la tabla"}],"output":"dict con {inserted: N filas insertadas sin error, errors: lista de errores por fila retornada por la API}"}


id: bq_list_datasets_py_infra
name: bq_list_datasets
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def bq_list_datasets(client: BQClient) -> list[dict]
tags: ["bigquery","gcp","dataset","list","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/datasets.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 14f5de1951d1f470428e6ae7648f2d8e1e92d9d31b9cfdc680a3a8f89663b9f5
description:
  Lista todos los datasets del proyecto de Google BigQuery. Usa client._client.list_datasets() del SDK oficial.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.datasets import bq_list_datasets

  client = bq_auth("my-project")
  datasets = bq_list_datasets(client)
  for ds in datasets:
      print(ds["dataset_id"], ds["full_id"])
  # analytics  my-project.analytics
  # raw_data   my-project.raw_data
  ```

notes:
  Retorna solo campos basicos (dataset_id, project, full_id). Para obtener detalles completos (location, description, labels) usar `bq_get_dataset` sobre cada item.
  El SDK retorna un iterador; esta funcion lo materializa en una lista completa.
  Si el proyecto no tiene datasets, retorna lista vacia `[]`.

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

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de BQClient"}],"output":"lista de dicts con dataset_id, project y full_id por cada dataset del proyecto"}


id: bq_list_jobs_py_infra
name: bq_list_jobs
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["bigquery","gcp","job","list","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/jobs.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: edd6fbfa4c18ec83a16b924cd201b2062d558dfe7ba84ad6bfadbf0b527ebd90
signature:
  def bq_list_jobs(client: BQClient, state_filter: str = '', max_results: int = 50, all_users: bool = False) -> list[dict]

description:
  Lista jobs del proyecto con filtro por estado (running, pending, done). Retorna una lista de dicts planos con metadatos de cada job.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.jobs import bq_list_jobs

  client = bq_auth("my-project")

  # Todos los jobs recientes
  jobs = bq_list_jobs(client, max_results=20)

  # Solo jobs en ejecucion
  running = bq_list_jobs(client, state_filter="running")
  for j in running:
      print(j["job_id"], j["state"], j["job_type"])

  # Jobs completados de todos los usuarios
  done = bq_list_jobs(client, state_filter="done", all_users=True, max_results=100)
  ```

notes:
  Los campos `created`, `started` y `ended` se serializan como strings ISO 8601 (o `None` si no estan disponibles).

  El campo `bytes_processed` solo esta disponible en query jobs; para otros tipos de job (load, export, copy) se retorna `None`.

  El campo `error` contiene el error_result como string si el job fallo, o `None` si no hay error.

  Para obtener el texto completo del SQL de un job concreto, usar `bq_get_job` que incluye el campo `query`.

code:
  """Gestion de jobs en Google BigQuery."""

  from .client import BQClient


  def bq_list_jobs(
      client: BQClient,
      state_filter: str = "",
      max_results: int = 50,
      all_users: bool = False,
  ) -> list[dict]:
      """Lista jobs del proyecto con filtro opcional por estado.

      Args:
          client: Cliente autenticado.
          state_filter: Filtro: "running", "pending", "done". Vacio = todos.
          max_results: Numero maximo de jobs a retornar.
          all_users: Si True, lista jobs de todos los usuarios (requiere permisos).

      Returns:
          Lista de dicts con: job_id, job_type, state, created, started, ended,
          user_email, bytes_processed, error.

      Example:
          >>> jobs = bq_list_jobs(client, state_filter="running")
          >>> for j in jobs:
          ...     print(j["job_id"], j["state"], j["job_type"])
      """
      kwargs = {"max_results": max_results, "all_users": all_users}
      if state_filter:
          kwargs["state_filter"] = state_filter
      return [_job_to_dict(job) for job in client._client.list_jobs(**kwargs)]


  def bq_get_job(client: BQClient, job_id: str) -> dict:
      """Obtiene detalles de un job por su ID.

      Args:
          client: Cliente autenticado.
          job_id: ID del job.

      Returns:
          Dict con: job_id, job_type, state, created, started, ended,
          user_email, bytes_processed, destination_table, query, error, errors.

      Raises:
          google.api_core.exceptions.NotFound: Si el job no existe.

      Example:
          >>> job = bq_get_job(client, "job_abc123")
          >>> print(job["state"], job["bytes_processed"])
          >>> if job["error"]:
          ...     print("Error:", job["error"])
      """
      job = client._client.get_job(job_id)
      result = _job_to_dict(job)
      # Agregar detalles extra disponibles en get pero no en list
      if hasattr(job, 'destination') and job.destination:
          result["destination_table"] = str(job.destination)
      if hasattr(job, 'query') and job.query:
          result["query"] = job.query
      if job.errors:
          result["errors"] = [dict(e) for e in job.errors]
      return result


  def bq_cancel_job(client: BQClient, job_id: str) -> dict:
      """Cancela un job en ejecucion.

      Args:
          client: Cliente autenticado.
          job_id: ID del job a cancelar.

      Returns:
          Dict con: job_id, state (tras cancelacion).

      Raises:
          google.api_core.exceptions.NotFound: Si el job no existe.

      Example:
          >>> result = bq_cancel_job(client, "job_abc123")
          >>> print(result["state"])  # "DONE" (cancelled)
      """
      job = client._client.cancel_job(job_id)
      return {"job_id": job.job_id, "state": job.state}


  def _job_to_dict(job) -> dict:
      """Convierte un objeto Job del SDK a dict plano."""
      result = {
          "job_id": job.job_id,
          "job_type": job.job_type,
          "state": job.state,
          "created": job.created.isoformat() if job.created else None,
          "started": job.started.isoformat() if job.started else None,
          "ended": job.ended.isoformat() if job.ended else None,
          "user_email": job.user_email,
          "error": str(job.error_result) if job.error_result else None,
      }
      # bytes_processed solo disponible en query jobs
      try:
          result["bytes_processed"] = job.total_bytes_processed
      except AttributeError:
          result["bytes_processed"] = None
      return result

params_schema:
  {"params":[{"name":"client","desc":"cliente autenticado de BigQuery obtenido con bq_auth"},{"name":"state_filter","desc":"filtro por estado del job: 'running', 'pending' o 'done'; vacio = todos los estados"},{"name":"max_results","desc":"numero maximo de jobs a retornar (por defecto 50)"},{"name":"all_users","desc":"si True lista jobs de todos los usuarios del proyecto; requiere permisos de administrador"}],"output":"lista de dicts con campos: job_id, job_type, state, created, started, ended, user_email, bytes_processed, error"}


id: bq_list_routines_py_infra
name: bq_list_routines
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def bq_list_routines(client: BQClient, dataset_id: str) -> list[dict]
description: Lista routines de un dataset incluyendo tipo y lenguaje.
tags: ["bigquery","gcp","routine","udf","list","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/routines.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: e02e9c7a8aa5320857ee6f5dc205a95407b0cbd93fbbfe963394139e47ad1da5
example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.routines import bq_list_routines

  client = bq_auth("my-project")
  routines = bq_list_routines(client, "analytics")

  for r in routines:
      print(r["routine_id"], r["routine_type"], r["language"])
  # double_value SCALAR_FUNCTION SQL
  # refresh_summary PROCEDURE SQL
  # parse_json SCALAR_FUNCTION JAVASCRIPT
  ```

notes:
  Retorna lista vacia si el dataset no tiene routines. Lanza `google.api_core.exceptions.NotFound` si el dataset no existe.

  El listado solo incluye metadata basica (routine_id, tipo, lenguaje). Para obtener el cuerpo y argumentos completos de una routine especifica, usar `client._client.get_routine(f"{project}.{dataset}.{routine_id}")`.

code:
  """Gestion de routines (UDFs y stored procedures) en Google BigQuery."""

  from .client import BQClient
  from google.cloud import bigquery


  def bq_create_routine(
      client: BQClient,
      dataset_id: str,
      routine_id: str,
      body: str,
      routine_type: str = "SCALAR_FUNCTION",
      language: str = "SQL",
      arguments: list[dict] | None = None,
      return_type: str = "",
      description: str = "",
  ) -> dict:
      """Crea una routine (UDF o stored procedure) en BigQuery.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset donde crear la routine.
          routine_id: ID/nombre de la routine.
          body: Cuerpo de la routine (SQL, JavaScript o Python).
          routine_type: Tipo: "SCALAR_FUNCTION", "TABLE_VALUED_FUNCTION", "PROCEDURE".
          language: Lenguaje: "SQL", "JAVASCRIPT", "PYTHON".
          arguments: Lista de argumentos, cada uno: {"name": "x", "data_type": "STRING"}.
          return_type: Tipo de retorno para funciones (ej: "STRING", "INT64"). No aplica a procedures.
          description: Descripcion opcional.

      Returns:
          Dict con: routine_id, dataset_id, project, routine_type, language, body, created, modified.

      Raises:
          google.api_core.exceptions.Conflict: Si la routine ya existe.

      Example:
          >>> bq_create_routine(client, "analytics", "double_value",
          ...     body="x * 2",
          ...     arguments=[{"name": "x", "data_type": "INT64"}],
          ...     return_type="INT64")
      """
      ref = bigquery.RoutineReference.from_string(
          f"{client.project_id}.{dataset_id}.{routine_id}"
      )
      routine = bigquery.Routine(ref)
      routine.type_ = routine_type
      routine.language = language
      routine.body = body
      if description:
          routine.description = description
      if arguments:
          routine.arguments = [
              bigquery.RoutineArgument(
                  name=arg["name"],
                  data_type=bigquery.StandardSqlDataType(
                      type_kind=getattr(
                          bigquery.StandardSqlTypeNames, arg["data_type"]
                      )
                  ),
              )
              for arg in arguments
          ]
      if return_type:
          routine.return_type = bigquery.StandardSqlDataType(
              type_kind=getattr(bigquery.StandardSqlTypeNames, return_type)
          )
      created = client._client.create_routine(routine)
      return _routine_to_dict(created)


  def bq_list_routines(client: BQClient, dataset_id: str) -> list[dict]:
      """Lista routines de un dataset.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.

      Returns:
          Lista de dicts con: routine_id, dataset_id, project, routine_type, language.

      Example:
          >>> routines = bq_list_routines(client, "analytics")
          >>> for r in routines:
          ...     print(r["routine_id"], r["routine_type"], r["language"])
      """
      ref = f"{client.project_id}.{dataset_id}"
      return [
          {
              "routine_id": r.routine_id,
              "dataset_id": dataset_id,
              "project": client.project_id,
              "routine_type": r.type_,
              "language": r.language,
          }
          for r in client._client.list_routines(ref)
      ]


  def bq_delete_routine(
      client: BQClient,
      dataset_id: str,
      routine_id: str,
  ) -> None:
      """Elimina una routine.

      Args:
          client: Cliente autenticado.
          dataset_id: ID del dataset.
          routine_id: ID de la routine a eliminar.

      Raises:
          google.api_core.exceptions.NotFound: Si la routine no existe.

      Example:
          >>> bq_delete_routine(client, "analytics", "double_value")
      """
      ref = f"{client.project_id}.{dataset_id}.{routine_id}"
      client._client.delete_routine(ref)


  def _routine_to_dict(routine) -> dict:
      """Convierte un objeto Routine del SDK a dict plano."""
      return {
          "routine_id": routine.routine_id,
          "dataset_id": routine.dataset_id,
          "project": routine.project,
          "routine_type": routine.type_,
          "language": routine.language,
          "body": routine.body,
          "description": routine.description or "",
          "created": routine.created.isoformat() if routine.created else None,
          "modified": routine.modified.isoformat() if routine.modified else None,
      }

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de BQClient"},{"name":"dataset_id","desc":"nombre del dataset cuyas routines se quieren listar"}],"output":"lista de dicts con routine_id, dataset_id, project, routine_type y language para cada routine del dataset"}


id: bq_list_tables_py_infra
name: bq_list_tables
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def bq_list_tables(client: BQClient, dataset_id: str) -> list[dict]
tags: ["bigquery","gcp","table","list","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/tables.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 838b843ab86012aebf67a343b23aa8b033b7d04c42089abc99a9ff6a75e927fb
description:
  Lista todas las tablas (y vistas) de un dataset BigQuery con informacion resumida. Usa client._client.list_tables() del SDK oficial.

example:
  ```python
  from bigquery import bq_auth, bq_list_tables

  client = bq_auth("mi-proyecto")

  tablas = bq_list_tables(client, "ventas_ds")
  for t in tablas:
      print(t["table_id"], t["type"])
      # transacciones TABLE
      # vista_mensual VIEW

  # Filtrar solo tablas fisicas
  solo_tablas = [t for t in tablas if t["type"] == "TABLE"]
  ```

notes:
  Retorna objetos resumidos (`ListTableItem`), no los metadatos completos. Para obtener schema, estadisticas y configuracion completa usar `bq_get_table`. El listado incluye tablas, vistas, vistas materializadas y tablas externas (EXTERNAL). El orden de retorno no esta garantizado.

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

params_schema:
  {"params":[{"name":"client","desc":"cliente autenticado BQClient obtenido con bq_auth"},{"name":"dataset_id","desc":"ID del dataset a listar"}],"output":"lista de dicts, uno por objeto del dataset. Cada dict contiene: table_id (nombre), full_id (project.dataset.table), type (TABLE, VIEW, MATERIALIZED_VIEW, EXTERNAL)"}


id: bq_load_from_file_py_infra
name: bq_load_from_file
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["bigquery","gcp","load","file","google-cloud","python","etl"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/queries.py
created_at: 2026-04-07T09:56:45Z
updated_at: 2026-04-07T09:56:45Z
props: []
emits: []
variant: []
content_hash: 62dc9cf942b94d0c28c2ce33472e1386e87142391205c15364666636676499c2
signature:
  def bq_load_from_file(client: BQClient, file_path: str, dataset_id: str, table_id: str, source_format: str = 'CSV', write_disposition: str = 'WRITE_APPEND', autodetect: bool = True, skip_leading_rows: int = 0) -> dict

description:
  Carga datos desde un archivo local a una tabla BigQuery usando load_table_from_file del SDK. Equivalente a bq_load_from_gcs pero para disco local.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.queries import bq_load_from_file

  client = bq_auth("my-project")

  # Cargar CSV local con cabecera
  result = bq_load_from_file(
      client,
      "/tmp/export_users.csv",
      "my_dataset", "users",
      skip_leading_rows=1,
      write_disposition="WRITE_TRUNCATE",
  )
  print(f"Cargadas {result['rows_loaded']} filas — job: {result['job_id']}")

  # Cargar JSONL local
  result = bq_load_from_file(
      client,
      "/data/events.jsonl",
      "my_dataset", "events",
      source_format="NEWLINE_DELIMITED_JSON",
  )
  ```

notes:
  El archivo se abre en modo binario (`rb`) y se sube directamente al job de BigQuery.
  Para archivos muy grandes, preferir `bq_load_from_gcs` — subir primero a GCS y luego
  cargar desde ahi es mas eficiente y permite paralelismo.

  La funcion bloquea hasta que el job termina (`job.result()`). Los archivos Parquet y
  Avro no admiten `skip_leading_rows` — ese parametro solo aplica para CSV.

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

params_schema:
  {"params":[{"name":"client","desc":"cliente BQClient autenticado contra el proyecto GCP"},{"name":"file_path","desc":"ruta absoluta o relativa al archivo local a cargar"},{"name":"dataset_id","desc":"ID del dataset de destino en BigQuery"},{"name":"table_id","desc":"ID de la tabla de destino en BigQuery"},{"name":"source_format","desc":"formato del archivo fuente: CSV, NEWLINE_DELIMITED_JSON, AVRO, PARQUET, ORC"},{"name":"write_disposition","desc":"comportamiento si la tabla ya existe: WRITE_APPEND agrega, WRITE_TRUNCATE reemplaza, WRITE_EMPTY falla si hay datos"},{"name":"autodetect","desc":"si True, BigQuery infiere el schema automaticamente desde los datos"},{"name":"skip_leading_rows","desc":"numero de filas a ignorar al inicio del archivo (tipicamente 1 para saltar cabeceras CSV)"}],"output":"dict con {job_id: ID del LoadJob, rows_loaded: filas cargadas, status: DONE o FAILED}"}


id: bq_load_from_gcs_py_infra
name: bq_load_from_gcs
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["bigquery","gcp","load","gcs","google-cloud","python","etl"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/queries.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 7a0ef734f51a22ae64a9e632e334b7298beb8442d6588636c61439d4e67ef3ed
signature:
  def bq_load_from_gcs(client: BQClient, uri: str | list[str], dataset_id: str, table_id: str, source_format: str = 'CSV', write_disposition: str = 'WRITE_APPEND', autodetect: bool = True, skip_leading_rows: int = 0) -> dict

description:
  Carga datos desde uno o varios URIs de Google Cloud Storage a una tabla BigQuery configurando un LoadJob. Espera la finalizacion del job.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.queries import bq_load_from_gcs

  client = bq_auth("my-project")

  # Cargar un archivo CSV con cabecera
  result = bq_load_from_gcs(
      client,
      "gs://my-bucket/data/users_2024.csv",
      "my_dataset", "users",
      skip_leading_rows=1,
  )
  print(f"Cargadas {result['rows_loaded']} filas — job: {result['job_id']}")

  # Cargar multiples archivos Parquet reemplazando la tabla
  result = bq_load_from_gcs(
      client,
      ["gs://my-bucket/export/part_001.parquet", "gs://my-bucket/export/part_002.parquet"],
      "my_dataset", "events",
      source_format="PARQUET",
      write_disposition="WRITE_TRUNCATE",
  )
  ```

notes:
  El job se ejecuta de forma asincrona en BigQuery; `job.result()` bloquea hasta completar.

  Los wildcards en el URI (`gs://bucket/prefix_*.csv`) son resueltos por GCS — BigQuery
  acepta la lista de archivos resultante como una sola carga atomica.

  `autodetect=True` es conveniente pero puede inferir tipos incorrectamente para columnas
  con valores nulos o mixtos. Para produccion, definir el schema explicitamente via
  `job_config.schema`.

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

params_schema:
  {"params":[{"name":"client","desc":"cliente BQClient autenticado contra el proyecto GCP"},{"name":"uri","desc":"URI de GCS de origen (gs://bucket/file.csv) o lista de URIs; soporta wildcards como gs://bucket/prefix_*.csv"},{"name":"dataset_id","desc":"ID del dataset de destino en BigQuery"},{"name":"table_id","desc":"ID de la tabla de destino en BigQuery"},{"name":"source_format","desc":"formato del archivo fuente: CSV, NEWLINE_DELIMITED_JSON, AVRO, PARQUET, ORC"},{"name":"write_disposition","desc":"comportamiento si la tabla ya existe: WRITE_APPEND agrega, WRITE_TRUNCATE reemplaza, WRITE_EMPTY falla si hay datos"},{"name":"autodetect","desc":"si True, BigQuery infiere el schema automaticamente desde los datos"},{"name":"skip_leading_rows","desc":"numero de filas a ignorar al inicio del archivo (tipicamente 1 para saltar cabeceras CSV)"}],"output":"dict con {job_id: ID del LoadJob, rows_loaded: filas cargadas, status: DONE o FAILED}"}


id: bq_preview_rows_py_infra
name: bq_preview_rows
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["bigquery","gcp","table","preview","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/tables.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 0774694fcdd4158647410234c911531c203a98c0fb7428f63f804da40b7227bd
signature:
  def bq_preview_rows(client: BQClient, dataset_id: str, table_id: str, max_results: int = 10) -> dict

description:
  Obtiene una muestra de filas de una tabla BigQuery sin ejecutar query SQL, sin coste de procesamiento. Usa client._client.list_rows() del SDK oficial.

example:
  ```python
  from bigquery import bq_auth, bq_preview_rows

  client = bq_auth("mi-proyecto")

  preview = bq_preview_rows(client, "ventas_ds", "transacciones", max_results=5)

  print(preview["columns"])         # ["id", "fecha", "monto", "pais"]
  print(f"Total filas: {preview['total_rows']}")
  for row in preview["rows"]:
      print(row)                    # [1, datetime.date(2024, 1, 15), 99.5, "MX"]
  ```

notes:
  `list_rows()` usa la Storage Read API internamente y NO genera un job de query — por tanto no se contabiliza en el uso de bytes procesados. Ideal para inspeccionar rapidamente la estructura y contenido de una tabla. El orden de las filas retornadas no esta garantizado (depende del almacenamiento interno de BigQuery). Para muestras reproducibles o con filtros, usar una query SQL con `LIMIT`. `total_rows` refleja el conteo de la tabla en el momento de la llamada a `get_table()`, que puede tener un pequeno retraso respecto al dato real.

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

params_schema:
  {"params":[{"name":"client","desc":"cliente autenticado BQClient obtenido con bq_auth"},{"name":"dataset_id","desc":"ID del dataset que contiene la tabla"},{"name":"table_id","desc":"nombre (ID) de la tabla a previsualizar"},{"name":"max_results","desc":"numero maximo de filas a retornar (default: 10)"}],"output":"dict con: columns (lista de nombres de columnas), rows (lista de listas con valores), total_rows (int con total de filas en la tabla completa)"}


id: bq_query_py_infra
name: bq_query
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["bigquery","gcp","query","sql","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/queries.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 37a3af3caa5aa5cf0ca25fcd2f64446261a98296da66e539be289b648d03411b
signature:
  def bq_query(client: BQClient, sql: str, params: list[dict] | None = None, dry_run: bool = False) -> dict

description:
  Ejecuta una query SQL en BigQuery con soporte para parametros tipados y modo dry-run para estimacion de costos.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.queries import bq_query

  client = bq_auth("my-project")

  # Query simple
  result = bq_query(client, "SELECT COUNT(*) as n FROM `my_project.dataset.table`")
  print(result["columns"])  # ["n"]
  print(result["rows"])     # [[12345]]

  # Con parametros
  result = bq_query(
      client,
      "SELECT * FROM `dataset.users` WHERE status = @s AND age > @a",
      params=[
          {"name": "s", "type": "STRING", "value": "active"},
          {"name": "a", "type": "INT64",  "value": 18},
      ],
  )

  # Dry-run para estimar costo
  est = bq_query(client, "SELECT * FROM `dataset.big_table`", dry_run=True)
  print(f"Procesaria {est['total_bytes_processed'] / 1e9:.2f} GB")
  ```

notes:
  Usa `bigquery.ScalarQueryParameter` para parametros — solo soporta escalares. Para arrays
  usar `bigquery.ArrayQueryParameter` directamente si se necesita.

  En dry_run=True el job se crea pero no se ejecuta; `job.result()` no se llama. BigQuery
  retorna la estimacion de bytes sin cargo.

  `cache_hit=True` indica que el resultado provino de la cache de BigQuery (sin costo).

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

params_schema:
  {"params":[{"name":"client","desc":"cliente BQClient autenticado contra el proyecto GCP"},{"name":"sql","desc":"query SQL a ejecutar; usar @nombre para referencias a parametros tipados"},{"name":"params","desc":"lista de parametros tipados, cada uno con {name, type, value}; tipos: STRING, INT64, FLOAT64, BOOL, DATE, TIMESTAMP"},{"name":"dry_run","desc":"si True, estima bytes procesados/facturados sin ejecutar la query"}],"output":"si dry_run=True: {total_bytes_processed, total_bytes_billed}; si False: {columns, rows, total_rows, bytes_processed, cache_hit}"}


id: bq_update_dataset_py_infra
name: bq_update_dataset
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["bigquery","gcp","dataset","update","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/datasets.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 497bd7e440ea911168b1c34b385eed136bf8759f72c20b20660e9ef4888af92e
signature:
  def bq_update_dataset(client: BQClient, dataset_id: str, description: str | None = None, labels: dict[str, str] | None = None, default_table_expiration_ms: int | None = None) -> dict

description:
  Actualiza campos de un dataset de Google BigQuery. Solo modifica los campos pasados explicitamente (no-None). Usa client._client.update_dataset() del SDK oficial.

example:
  ```python
  from bigquery.client import bq_auth
  from bigquery.datasets import bq_update_dataset

  client = bq_auth("my-project")

  # Actualizar solo la descripcion
  ds = bq_update_dataset(client, "analytics", description="Data warehouse actualizado")

  # Actualizar labels
  ds = bq_update_dataset(client, "analytics", labels={"env": "prod", "version": "2"})

  # Eliminar expiracion de tablas
  ds = bq_update_dataset(client, "analytics", default_table_expiration_ms=0)
  print(ds["description"])
  ```

notes:
  Lanza `google.api_core.exceptions.NotFound` (404) si el dataset no existe.
  Si no se pasa ningun campo (todos None), retorna el dataset sin modificar (no llama a update_dataset).
  Para eliminar todos los labels pasar `labels={}`. Para eliminar la expiracion de tablas pasar `default_table_expiration_ms=0`.
  El SDK hace un GET interno antes del PATCH para obtener el estado actual; esta funcion replica ese patron explicitamente.

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

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de BQClient"},{"name":"dataset_id","desc":"nombre del dataset a actualizar (sin prefijo de proyecto)"},{"name":"description","desc":"nueva descripcion del dataset; None = no modificar"},{"name":"labels","desc":"nuevos labels key-value; None = no modificar; dict vacio {} = eliminar todos los labels"},{"name":"default_table_expiration_ms","desc":"nueva expiracion por defecto de tablas en ms; None = no modificar; 0 = eliminar expiracion"}],"output":"dict con el dataset actualizado: dataset_id, project, full_id, location, description, labels, created, modified, default_table_expiration_ms"}


id: bq_update_table_py_infra
name: bq_update_table
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["bigquery","gcp","table","update","google-cloud","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["google-cloud-bigquery"]
tests: []
file_path: python/functions/bigquery/tables.py
created_at: 2026-04-07T09:56:37Z
updated_at: 2026-04-07T09:56:37Z
props: []
emits: []
variant: []
content_hash: 69a3b58d265a0e7420c353fe1b5d7d95e9c83c2afd3d8b7a01d48354f0f81b4d
signature:
  def bq_update_table(client: BQClient, dataset_id: str, table_id: str, schema: list[dict] | None = None, description: str | None = None, labels: dict | None = None) -> dict

description:
  Actualiza metadatos de una tabla BigQuery: schema (solo adicion de columnas), descripcion y etiquetas. Usa client._client.update_table() del SDK oficial.

example:
  ```python
  from bigquery import bq_auth, bq_update_table

  client = bq_auth("mi-proyecto")

  # Actualizar descripcion y etiquetas
  tabla = bq_update_table(
      client, "ventas_ds", "transacciones",
      description="Transacciones de ventas — actualizado",
      labels={"env": "prod", "team": "data", "version": "2"},
  )

  # Agregar columna nueva al schema existente
  tabla = bq_update_table(
      client, "ventas_ds", "transacciones",
      schema=[
          {"name": "id", "type": "INTEGER", "mode": "REQUIRED"},
          {"name": "fecha", "type": "DATE", "mode": "NULLABLE"},
          {"name": "monto", "type": "FLOAT", "mode": "NULLABLE"},
          {"name": "nueva_col", "type": "STRING", "mode": "NULLABLE"},  # nueva
      ],
  )
  ```

notes:
  BigQuery tiene restricciones estrictas sobre modificacion de schema: se pueden agregar columnas NULLABLE o REPEATED al final, pero NO se pueden eliminar columnas, renombrar columnas ni cambiar el tipo de una columna existente. Si se necesita ese tipo de cambio, la alternativa es crear una tabla nueva con `CREATE TABLE AS SELECT`. Los campos `None` no generan actualizacion — solo se envian al API los campos que cambian.

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

params_schema:
  {"params":[{"name":"client","desc":"cliente autenticado BQClient obtenido con bq_auth"},{"name":"dataset_id","desc":"ID del dataset que contiene la tabla"},{"name":"table_id","desc":"nombre (ID) de la tabla a actualizar"},{"name":"schema","desc":"schema completo nuevo (columnas existentes + nuevas al final). BigQuery SOLO permite agregar columnas, no eliminar ni renombrar. None = no modificar"},{"name":"description","desc":"nueva descripcion de la tabla. None = no modificar"},{"name":"labels","desc":"nuevas etiquetas clave-valor (reemplaza las existentes). None = no modificar"}],"output":"dict con la metadata actualizada de la tabla (misma estructura que bq_get_table)"}


id: all_of_py_core
name: all_of
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
signature: def all_of(xs: list, pred: callable) -> bool
description: Retorna True si todos los elementos de la lista cumplen el predicado.
tags: ["list","functional","predicate","all","python"]
uses_functions: []
uses_types: []
returns: []
imports: []
example:
  ```python
  result = all_of([2, 4, 6], lambda n: n % 2 == 0)
  # True
  ```

tests: []
file_path: python/functions/core/core.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Funcion pura. Retorna True para lista vacia (vacuamente verdadero). Cortocircuita al primer False.
content_hash: c4adf0c65600e1da0e7c05c9e30f64c684ee40e5ba09ff04a2781eb8d510556e
code:
  """Core functional programming utilities — pure functions for list/collection operations."""

  import hashlib
  import re
  from functools import reduce as _reduce
  from typing import Any, Callable, Dict, List, Optional, Tuple


  def filter_list(xs: list, pred: Callable) -> list:
      """Filter list by predicate. Does not mutate the original."""
      return [x for x in xs if pred(x)]


  def map_list(xs: list, fn: Callable) -> list:
      """Map function over list. Does not mutate the original."""
      return [fn(x) for x in xs]


  def reduce_list(xs: list, initial: Any, fn: Callable) -> Any:
      """Reduce list with accumulator. fn(acc, x) -> acc."""
      return _reduce(fn, xs, initial)


  def flat_map(xs: list, fn: Callable) -> list:
      """Map function over list then flatten one level."""
      result = []
      for x in xs:
          result.extend(fn(x))
      return result


  def flatten(xss: list) -> list:
      """Flatten a list of lists one level."""
      result = []
      for xs in xss:
          result.extend(xs)
      return result


  def chunk(xs: list, size: int) -> list:
      """Split list into chunks of given size. Last chunk may be smaller."""
      if size <= 0:
          return []
      return [xs[i : i + size] for i in range(0, len(xs), size)]


  def take(xs: list, n: int) -> list:
      """Take first n elements from list."""
      return xs[:n]


  def drop(xs: list, n: int) -> list:
      """Drop first n elements from list."""
      return xs[n:]


  def unique(xs: list) -> list:
      """Remove duplicates preserving order. Uses identity for hashable elements."""
      seen = set()
      result = []
      for x in xs:
          if x not in seen:
              seen.add(x)
              result.append(x)
      return result


  def group_by(xs: list, key_fn: Callable) -> Dict:
      """Group elements by key function. Returns dict of key -> list."""
      groups: Dict = {}
      for x in xs:
          k = key_fn(x)
          if k not in groups:
              groups[k] = []
          groups[k].append(x)
      return groups


  def partition(xs: list, pred: Callable) -> Tuple[list, list]:
      """Split list into (matches, non_matches) based on predicate."""
      matches = []
      non_matches = []
      for x in xs:
          if pred(x):
              matches.append(x)
          else:
              non_matches.append(x)
      return (matches, non_matches)


  def find(xs: list, pred: Callable) -> Any:
      """Find first element matching predicate. Returns None if not found."""
      for x in xs:
          if pred(x):
              return x
      return None


  def find_index(xs: list, pred: Callable) -> int:
      """Find index of first element matching predicate. Returns -1 if not found."""
      for i, x in enumerate(xs):
          if pred(x):
              return i
      return -1


  def zip_with(xs: list, ys: list, fn: Callable) -> list:
      """Zip two lists with a combining function. Stops at shorter list."""
      return [fn(x, y) for x, y in zip(xs, ys)]


  def all_of(xs: list, pred: Callable) -> bool:
      """Return True if all elements match predicate."""
      return all(pred(x) for x in xs)


  def any_of(xs: list, pred: Callable) -> bool:
      """Return True if any element matches predicate."""
      return any(pred(x) for x in xs)


  def pipe(value: Any, *fns: Callable) -> Any:
      """Pipe a value through a sequence of functions left-to-right."""
      result = value
      for fn in fns:
          result = fn(result)
      return result


  def compose(*fns: Callable) -> Callable:
      """Compose functions right-to-left. compose(f, g)(x) == f(g(x))."""
      def composed(x: Any) -> Any:
          result = x
          for fn in reversed(fns):
              result = fn(result)
          return result
      return composed


  # ── Tree manipulation ────────────────────────────────────────────────────────


  def flatten_tree(structure: Any) -> List[Dict]:
      """Flatten a hierarchical tree (dict with 'nodes') to a list without children."""
      import copy
      if isinstance(structure, dict):
          node = copy.deepcopy(structure)
          node.pop('nodes', None)
          nodes = [node]
          for key in list(structure.keys()):
              if 'nodes' in key:
                  nodes.extend(flatten_tree(structure[key]))
          return nodes
      elif isinstance(structure, list):
          nodes = []
          for item in structure:
              nodes.extend(flatten_tree(item))
          return nodes
      return []


  def tree_to_flat_list(structure: Any) -> List[Dict]:
      """Convert hierarchical tree to flat list preserving DFS order (keeps internal nodes)."""
      if isinstance(structure, dict):
          nodes = [structure]
          if 'nodes' in structure:
              nodes.extend(tree_to_flat_list(structure['nodes']))
          return nodes
      elif isinstance(structure, list):
          nodes = []
          for item in structure:
              nodes.extend(tree_to_flat_list(item))
          return nodes
      return []


  def get_leaf_nodes(structure: Any) -> List[Dict]:
      """Extract only leaf nodes (no children) from a hierarchical tree."""
      import copy
      if isinstance(structure, dict):
          if not structure.get('nodes'):
              node = copy.deepcopy(structure)
              node.pop('nodes', None)
              return [node]
          leaf_nodes = []
          for key in list(structure.keys()):
              if 'nodes' in key:
                  leaf_nodes.extend(get_leaf_nodes(structure[key]))
          return leaf_nodes
      elif isinstance(structure, list):
          leaf_nodes = []
          for item in structure:
              leaf_nodes.extend(get_leaf_nodes(item))
          return leaf_nodes
      return []


  def write_node_ids(data: Any, node_id: int = 0) -> int:
      """Assign sequential zero-padded IDs (0001, 0002...) to all nodes in a tree. Returns next counter."""
      if isinstance(data, dict):
          data['node_id'] = str(node_id).zfill(4)
          node_id += 1
          for key in list(data.keys()):
              if 'nodes' in key:
                  node_id = write_node_ids(data[key], node_id)
      elif isinstance(data, list):
          for item in data:
              node_id = write_node_ids(item, node_id)
      return node_id


  def list_to_tree(data: List[Dict]) -> List[Dict]:
      """Convert flat list with structure codes ('1.2.3') to nested tree."""
      def get_parent_structure(structure):
          if not structure:
              return None
          parts = str(structure).split('.')
          return '.'.join(parts[:-1]) if len(parts) > 1 else None

      nodes = {}
      root_nodes = []

      for item in data:
          structure = item.get('structure')
          node = {
              'title': item.get('title'),
              'start_index': item.get('start_index'),
              'end_index': item.get('end_index'),
              'nodes': []
          }
          nodes[structure] = node
          parent_structure = get_parent_structure(structure)

          if parent_structure and parent_structure in nodes:
              nodes[parent_structure]['nodes'].append(node)
          else:
              root_nodes.append(node)

      def clean_node(node):
          if not node['nodes']:
              del node['nodes']
          else:
              for child in node['nodes']:
                  clean_node(child)
          return node

      return [clean_node(node) for node in root_nodes]


  def remove_tree_fields(data: Any, fields: List[str] = None) -> Any:
      """Recursively remove specified fields from a tree (dict/list)."""
      if fields is None:
          fields = ['text']
      if isinstance(data, dict):
          return {k: remove_tree_fields(v, fields) for k, v in data.items() if k not in fields}
      elif isinstance(data, list):
          return [remove_tree_fields(item, fields) for item in data]
      return data


  def format_tree_structure(structure: Any, order: List[str] = None) -> Any:
      """Reorder fields of each node in a tree according to specified key order."""
      if not order:
          return structure
      if isinstance(structure, dict):
          if 'nodes' in structure:
              structure['nodes'] = format_tree_structure(structure['nodes'], order)
          if not structure.get('nodes'):
              structure.pop('nodes', None)
          return {key: structure[key] for key in order if key in structure}
      elif isinstance(structure, list):
          return [format_tree_structure(item, order) for item in structure]
      return structure


  def create_node_mapping(tree: List[Dict]) -> Dict[str, Dict]:
      """Create flat dict mapping node_id to node for O(1) lookup."""
      mapping = {}
      def _traverse(nodes):
          for node in nodes:
              if node.get('node_id'):
                  mapping[node['node_id']] = node
              if node.get('nodes'):
                  _traverse(node['nodes'])
      _traverse(tree)
      return mapping


  # ── Text / JSON extraction ───────────────────────────────────────────────────


  def extract_json_from_llm(content: str) -> Dict:
      """Extract and parse JSON from LLM responses. Handles ```json blocks, trailing commas, None->null."""
      import json
      try:
          start_idx = content.find("```json")
          if start_idx != -1:
              start_idx += 7
              end_idx = content.rfind("```")
              json_content = content[start_idx:end_idx].strip()
          else:
              json_content = content.strip()

          json_content = json_content.replace('None', 'null')
          json_content = json_content.replace('\n', ' ').replace('\r', ' ')
          json_content = ' '.join(json_content.split())

          return json.loads(json_content)
      except (json.JSONDecodeError, Exception):
          try:
              json_content = json_content.replace(',]', ']').replace(',}', '}')
              return json.loads(json_content)
          except Exception:
              return {}


  def parse_page_range(pages: str) -> List[int]:
      """Parse page range string ('5-7', '3,8', '12') into sorted list of unique ints."""
      result = []
      for part in pages.split(','):
          part = part.strip()
          if '-' in part:
              start, end = int(part.split('-', 1)[0].strip()), int(part.split('-', 1)[1].strip())
              if start > end:
                  raise ValueError(f"Invalid range '{part}': start must be <= end")
              result.extend(range(start, end + 1))
          else:
              result.append(int(part))
      return sorted(set(result))


  # ── Markdown parsing ─────────────────────────────────────────────────────────


  def extract_markdown_headers(markdown_content: str) -> Tuple[List[Dict], List[str]]:
      """Extract all headers (h1-h6) from markdown with line numbers, skipping code blocks."""
      import re
      header_pattern = r'^(#{1,6})\s+(.+)$'
      code_block_pattern = r'^```'
      node_list = []
      lines = markdown_content.split('\n')
      in_code_block = False

      for line_num, line in enumerate(lines, 1):
          stripped_line = line.strip()
          if re.match(code_block_pattern, stripped_line):
              in_code_block = not in_code_block
              continue
          if not stripped_line:
              continue
          if not in_code_block:
              match = re.match(header_pattern, stripped_line)
              if match:
                  level = len(match.group(1))
                  title = match.group(2).strip()
                  node_list.append({'title': title, 'level': level, 'line_num': line_num})

      return node_list, lines


  def build_tree_from_headers(node_list: List[Dict]) -> List[Dict]:
      """Build nested tree from flat list of headers with levels (h1>h2>h3)."""
      if not node_list:
          return []

      stack = []
      root_nodes = []
      node_counter = 1

      for node in node_list:
          current_level = node['level']
          tree_node = {
              'title': node['title'],
              'node_id': str(node_counter).zfill(4),
              'line_num': node['line_num'],
              'nodes': []
          }
          node_counter += 1

          while stack and stack[-1][1] >= current_level:
              stack.pop()

          if not stack:
              root_nodes.append(tree_node)
          else:
              parent_node, _ = stack[-1]
              parent_node['nodes'].append(tree_node)

          stack.append((tree_node, current_level))

      def clean_empty_nodes(nodes):
          for n in nodes:
              if n['nodes']:
                  clean_empty_nodes(n['nodes'])
              else:
                  del n['nodes']
          return nodes

      return clean_empty_nodes(root_nodes)


  # ── Pagination / chunking ────────────────────────────────────────────────────


  def page_list_to_groups(page_contents: List[str], token_lengths: List[int],
                          max_tokens: int = 20000, overlap_pages: int = 1) -> List[str]:
      """Group pages into text chunks respecting token limit with configurable overlap."""
      import math
      num_tokens = sum(token_lengths)

      if num_tokens <= max_tokens:
          return ["".join(page_contents)]

      subsets = []
      current_subset = []
      current_token_count = 0

      expected_parts = math.ceil(num_tokens / max_tokens)
      avg_tokens = math.ceil(((num_tokens / expected_parts) + max_tokens) / 2)

      for i, (page_content, page_tokens) in enumerate(zip(page_contents, token_lengths)):
          if current_token_count + page_tokens > avg_tokens:
              subsets.append(''.join(current_subset))
              overlap_start = max(i - overlap_pages, 0)
              current_subset = list(page_contents[overlap_start:i])
              current_token_count = sum(token_lengths[overlap_start:i])

          current_subset.append(page_content)
          current_token_count += page_tokens

      if current_subset:
          subsets.append(''.join(current_subset))

      return subsets


  def calculate_page_offset(pairs: List[Dict]) -> int:
      """Calculate offset between logical page numbers and physical indices using reference pairs."""
      differences = []
      for pair in pairs:
          try:
              difference = pair['physical_index'] - pair['page']
              differences.append(difference)
          except (KeyError, TypeError):
              continue

      if not differences:
          return 0

      counts: Dict[int, int] = {}
      for diff in differences:
          counts[diff] = counts.get(diff, 0) + 1

      return max(counts.items(), key=lambda x: x[1])[0]


  # ── Text preprocessing ───────────────────────────────────────────────────────


  def preprocess_text(text: str) -> str:
      """Normalize whitespace and newlines in raw text.

      Args:
          text: Raw text to normalize.

      Returns:
          Normalized text with consistent newlines, stripped lines, and no
          excessive blank lines.
      """
      # Normalize line endings: \r\n and \r -> \n
      text = text.replace('\r\n', '\n').replace('\r', '\n')
      # Reduce 3+ consecutive newlines to at most 2
      text = re.sub(r'\n{3,}', '\n\n', text)
      # Strip whitespace from each line
      text = '\n'.join(line.strip() for line in text.split('\n'))
      # Strip globally
      return text.strip()


  def get_text_stats(text: str) -> dict:
      """Compute basic statistics of a text: characters, lines, words.

      Args:
          text: Input text to analyze.

      Returns:
          Dict with keys total_chars (int), total_lines (int), total_words (int).
      """
      return {
          'total_chars': len(text),
          'total_lines': text.count('\n') + 1,
          'total_words': len(text.split()),
      }


  # ── Git URL parsing ──────────────────────────────────────────────────────────

  _DEFAULT_GIT_HOSTS = ["github.com", "gitlab.com"]


  def _sanitize_git_segment(segment: str) -> str:
      """Strip .git suffix then keep only [a-zA-Z0-9_-] chars."""
      if segment.endswith(".git"):
          segment = segment[:-4]
      return re.sub(r"[^a-zA-Z0-9_\-]", "", segment)


  def parse_git_url(url: str, known_hosts: Optional[List[str]] = None) -> Optional[str]:
      """Parse a code-hosting URL and return the 'org/repo' path component.

      Supports HTTPS, HTTP, git://, ssh:// and SSH shorthand (git@host:path).
      Returns None if the URL does not match any known host or is malformed.

      Args:
          url: Repository URL in any supported format.
          known_hosts: List of accepted hostnames. Defaults to github.com and gitlab.com.

      Returns:
          'org/repo' string or None.
      """
      from urllib.parse import urlparse

      hosts = known_hosts if known_hosts is not None else _DEFAULT_GIT_HOSTS
      url = url.strip()

      if url.startswith("git@"):
          # git@github.com:org/repo.git
          rest = url[len("git@"):]
          if ":" not in rest:
              return None
          host, path = rest.split(":", 1)
          if host not in hosts:
              return None
          segments = [s for s in path.split("/") if s]
          if len(segments) < 2:
              return None
          org = _sanitize_git_segment(segments[0])
          repo = _sanitize_git_segment(segments[1])
          if not org or not repo:
              return None
          return f"{org}/{repo}"

      for prefix in ("http://", "https://", "git://", "ssh://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              netloc = parsed.hostname or ""
              if netloc not in hosts:
                  return None
              segments = [s for s in parsed.path.split("/") if s]
              if len(segments) < 2:
                  return None
              org = _sanitize_git_segment(segments[0])
              repo = _sanitize_git_segment(segments[1])
              if not org or not repo:
                  return None
              return f"{org}/{repo}"

      return None


  def is_git_repo_url(url: str, known_hosts: Optional[List[str]] = None) -> bool:
      """Return True only if url points to a clonable git repository.

      Accepts org/repo and org/repo/tree/<ref> paths.
      Rejects paths that navigate to sub-resources (issues, blobs, PRs, etc.).

      Args:
          url: URL to verify.
          known_hosts: Accepted hostnames. Defaults to github.com and gitlab.com.

      Returns:
          True if url is a clonable repository URL.
      """
      from urllib.parse import urlparse

      hosts = known_hosts if known_hosts is not None else _DEFAULT_GIT_HOSTS
      url = url.strip()

      # SSH shorthand — always repo-level if host matches
      if url.startswith("git@"):
          rest = url[len("git@"):]
          if ":" not in rest:
              return False
          host, _ = rest.split(":", 1)
          return host in hosts

      # git:// and ssh:// — always repo-level if host matches
      for prefix in ("ssh://", "git://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              return (parsed.hostname or "") in hosts

      # http:// and https:// — must have exactly org/repo or org/repo/tree/<ref>
      for prefix in ("http://", "https://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              if (parsed.hostname or "") not in hosts:
                  return False
              segments = [s for s in parsed.path.split("/") if s]
              if len(segments) == 2:
                  return True
              if len(segments) == 4 and segments[2] == "tree":
                  return True
              return False

      return False


  def validate_git_ssh_uri(url: str) -> None:
      """Validate a git SSH URI of the form git@host:path.

      Raises ValueError with a descriptive message if the URI is malformed.

      Args:
          url: URI string to validate.

      Raises:
          ValueError: If the URI does not conform to git SSH format.
      """
      if not url.startswith("git@"):
          raise ValueError(f"git SSH URI must start with 'git@', got: {url!r}")
      rest = url[len("git@"):]
      if ":" not in rest:
          raise ValueError(f"git SSH URI must contain ':', got: {url!r}")
      _, path = rest.split(":", 1)
      if not path:
          raise ValueError(f"git SSH URI must have a non-empty path after ':', got: {url!r}")


  # ---------------------------------------------------------------------------
  # Markdown parsing utilities
  # ---------------------------------------------------------------------------


  def extract_frontmatter(content: str) -> Tuple[str, Optional[Dict]]:
      """Extract YAML frontmatter delimited by '---' from the start of a markdown string.

      Args:
          content: Raw markdown string, optionally starting with YAML frontmatter.

      Returns:
          Tuple of (content_without_frontmatter, frontmatter_dict).
          frontmatter_dict is None when no frontmatter is found.
      """
      pattern = re.compile(r'^---\n(.*?)\n---\n', re.DOTALL)
      match = pattern.match(content)
      if not match:
          return content, None

      raw = match.group(1)
      remaining = content[match.end():]

      try:
          import yaml  # type: ignore
          data = yaml.safe_load(raw)
          if not isinstance(data, dict):
              data = None
      except Exception:
          # Fallback: simple key: value parser (no yaml dependency)
          data = {}
          for line in raw.splitlines():
              if ':' in line:
                  key, _, value = line.partition(':')
                  data[key.strip()] = value.strip()

      return remaining, data


  def find_headings(content: str) -> List[Tuple[int, int, str, int]]:
      """Find all markdown headings (# to ######), excluding those inside code blocks,
      HTML comments, and indented blocks.

      Args:
          content: Markdown text to search.

      Returns:
          List of (start_pos, end_pos, title, level) for each heading found.
      """
      excluded: List[Tuple[int, int]] = []

      # Code blocks (triple backtick)
      for m in re.finditer(r'```.*?```', content, re.DOTALL):
          excluded.append((m.start(), m.end()))

      # HTML comments
      for m in re.finditer(r'<!--.*?-->', content, re.DOTALL):
          excluded.append((m.start(), m.end()))

      # Indented blocks (lines starting with 4 spaces or a tab)
      for m in re.finditer(r'^(    |\t).+$', content, re.MULTILINE):
          excluded.append((m.start(), m.end()))

      def is_excluded(pos: int) -> bool:
          return any(start <= pos < end for start, end in excluded)

      results: List[Tuple[int, int, str, int]] = []
      for m in re.finditer(r'^(#{1,6})\s+(.+)$', content, re.MULTILINE):
          # Skip escaped headings (\#)
          before = content[m.start() - 1] if m.start() > 0 else ''
          if before == '\\':
              continue
          if is_excluded(m.start()):
              continue
          level = len(m.group(1))
          title = m.group(2).strip()
          results.append((m.start(), m.end(), title, level))

      return results


  def estimate_token_count(content: str) -> int:
      """Estimate token count without a tokenizer.

      CJK characters count as ~0.7 tokens each; other non-whitespace characters
      count as ~0.3 tokens each.

      Args:
          content: Text to estimate.

      Returns:
          Estimated integer token count.
      """
      cjk = re.findall(r'[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]', content)
      without_cjk = re.sub(r'[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]', '', content)
      others = re.findall(r'\S', without_cjk)
      return int(len(cjk) * 0.7 + len(others) * 0.3)


  def smart_split_content(
      content: str,
      max_tokens: int = 1024,
      max_chars: int = 8000,
  ) -> List[str]:
      """Split large content into parts respecting token and character limits.

      Splits by paragraphs (double newline). If a single paragraph exceeds the
      limit it is force-cut into chunks of max_chars.

      Args:
          content: Text to split.
          max_tokens: Maximum estimated tokens per part.
          max_chars: Maximum characters per part.

      Returns:
          List of string parts.
      """
      paragraphs = content.split('\n\n')
      parts: List[str] = []
      current_parts: List[str] = []
      current_tokens = 0
      current_chars = 0

      def flush() -> None:
          if current_parts:
              parts.append('\n\n'.join(current_parts))
              current_parts.clear()

      for para in paragraphs:
          para_tokens = estimate_token_count(para)
          para_chars = len(para)

          # Single paragraph exceeds limits — force-cut it
          if para_tokens > max_tokens or para_chars > max_chars:
              flush()
              current_tokens = 0
              current_chars = 0
              for i in range(0, len(para), max_chars):
                  parts.append(para[i:i + max_chars])
              continue

          # Would exceed limits if added — flush first
          if (current_tokens + para_tokens > max_tokens or
                  current_chars + para_chars > max_chars):
              flush()
              current_tokens = 0
              current_chars = 0

          current_parts.append(para)
          current_tokens += para_tokens
          current_chars += para_chars

      flush()
      return parts if parts else [content]


  def sanitize_for_path(text: str, max_length: int = 50) -> str:
      """Convert text to a safe string for use in file paths.

      Keeps word characters, CJK characters, spaces and hyphens. Replaces spaces
      with underscores. Truncates with a sha256 suffix if the result exceeds
      max_length.

      Args:
          text: Input text to sanitize.
          max_length: Maximum length of the returned string.

      Returns:
          Safe path-friendly string.
      """
      cleaned = re.sub(
          r'[^\w\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af \-]',
          '',
          text,
      )
      cleaned = cleaned.replace(' ', '_').strip('_')

      if not cleaned:
          return 'section'

      if len(cleaned) <= max_length:
          return cleaned

      suffix = '_' + hashlib.sha256(text.encode()).hexdigest()[:8]
      return cleaned[:max_length - len(suffix)] + suffix

params_schema:
  {"params":[{"name":"xs","desc":"lista de elementos a evaluar"},{"name":"pred","desc":"predicado (funcion que retorna bool) para evaluar cada elemento"}],"output":"booleano: True si todos los elementos cumplen el predicado, False en caso contrario"}


id: any_of_py_core
name: any_of
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
signature: def any_of(xs: list, pred: callable) -> bool
description: Retorna True si al menos un elemento de la lista cumple el predicado.
tags: ["list","functional","predicate","any","python"]
uses_functions: []
uses_types: []
returns: []
imports: []
example:
  ```python
  result = any_of([1, 3, 5, 4], lambda n: n % 2 == 0)
  # True
  ```

tests: []
file_path: python/functions/core/core.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Funcion pura. Retorna False para lista vacia. Cortocircuita al primer True.
content_hash: 81eaac976d537b622956a9b2290b9f24101a3a19d0285fa415a2cad2637927e0
code:
  """Core functional programming utilities — pure functions for list/collection operations."""

  import hashlib
  import re
  from functools import reduce as _reduce
  from typing import Any, Callable, Dict, List, Optional, Tuple


  def filter_list(xs: list, pred: Callable) -> list:
      """Filter list by predicate. Does not mutate the original."""
      return [x for x in xs if pred(x)]


  def map_list(xs: list, fn: Callable) -> list:
      """Map function over list. Does not mutate the original."""
      return [fn(x) for x in xs]


  def reduce_list(xs: list, initial: Any, fn: Callable) -> Any:
      """Reduce list with accumulator. fn(acc, x) -> acc."""
      return _reduce(fn, xs, initial)


  def flat_map(xs: list, fn: Callable) -> list:
      """Map function over list then flatten one level."""
      result = []
      for x in xs:
          result.extend(fn(x))
      return result


  def flatten(xss: list) -> list:
      """Flatten a list of lists one level."""
      result = []
      for xs in xss:
          result.extend(xs)
      return result


  def chunk(xs: list, size: int) -> list:
      """Split list into chunks of given size. Last chunk may be smaller."""
      if size <= 0:
          return []
      return [xs[i : i + size] for i in range(0, len(xs), size)]


  def take(xs: list, n: int) -> list:
      """Take first n elements from list."""
      return xs[:n]


  def drop(xs: list, n: int) -> list:
      """Drop first n elements from list."""
      return xs[n:]


  def unique(xs: list) -> list:
      """Remove duplicates preserving order. Uses identity for hashable elements."""
      seen = set()
      result = []
      for x in xs:
          if x not in seen:
              seen.add(x)
              result.append(x)
      return result


  def group_by(xs: list, key_fn: Callable) -> Dict:
      """Group elements by key function. Returns dict of key -> list."""
      groups: Dict = {}
      for x in xs:
          k = key_fn(x)
          if k not in groups:
              groups[k] = []
          groups[k].append(x)
      return groups


  def partition(xs: list, pred: Callable) -> Tuple[list, list]:
      """Split list into (matches, non_matches) based on predicate."""
      matches = []
      non_matches = []
      for x in xs:
          if pred(x):
              matches.append(x)
          else:
              non_matches.append(x)
      return (matches, non_matches)


  def find(xs: list, pred: Callable) -> Any:
      """Find first element matching predicate. Returns None if not found."""
      for x in xs:
          if pred(x):
              return x
      return None


  def find_index(xs: list, pred: Callable) -> int:
      """Find index of first element matching predicate. Returns -1 if not found."""
      for i, x in enumerate(xs):
          if pred(x):
              return i
      return -1


  def zip_with(xs: list, ys: list, fn: Callable) -> list:
      """Zip two lists with a combining function. Stops at shorter list."""
      return [fn(x, y) for x, y in zip(xs, ys)]


  def all_of(xs: list, pred: Callable) -> bool:
      """Return True if all elements match predicate."""
      return all(pred(x) for x in xs)


  def any_of(xs: list, pred: Callable) -> bool:
      """Return True if any element matches predicate."""
      return any(pred(x) for x in xs)


  def pipe(value: Any, *fns: Callable) -> Any:
      """Pipe a value through a sequence of functions left-to-right."""
      result = value
      for fn in fns:
          result = fn(result)
      return result


  def compose(*fns: Callable) -> Callable:
      """Compose functions right-to-left. compose(f, g)(x) == f(g(x))."""
      def composed(x: Any) -> Any:
          result = x
          for fn in reversed(fns):
              result = fn(result)
          return result
      return composed


  # ── Tree manipulation ────────────────────────────────────────────────────────


  def flatten_tree(structure: Any) -> List[Dict]:
      """Flatten a hierarchical tree (dict with 'nodes') to a list without children."""
      import copy
      if isinstance(structure, dict):
          node = copy.deepcopy(structure)
          node.pop('nodes', None)
          nodes = [node]
          for key in list(structure.keys()):
              if 'nodes' in key:
                  nodes.extend(flatten_tree(structure[key]))
          return nodes
      elif isinstance(structure, list):
          nodes = []
          for item in structure:
              nodes.extend(flatten_tree(item))
          return nodes
      return []


  def tree_to_flat_list(structure: Any) -> List[Dict]:
      """Convert hierarchical tree to flat list preserving DFS order (keeps internal nodes)."""
      if isinstance(structure, dict):
          nodes = [structure]
          if 'nodes' in structure:
              nodes.extend(tree_to_flat_list(structure['nodes']))
          return nodes
      elif isinstance(structure, list):
          nodes = []
          for item in structure:
              nodes.extend(tree_to_flat_list(item))
          return nodes
      return []


  def get_leaf_nodes(structure: Any) -> List[Dict]:
      """Extract only leaf nodes (no children) from a hierarchical tree."""
      import copy
      if isinstance(structure, dict):
          if not structure.get('nodes'):
              node = copy.deepcopy(structure)
              node.pop('nodes', None)
              return [node]
          leaf_nodes = []
          for key in list(structure.keys()):
              if 'nodes' in key:
                  leaf_nodes.extend(get_leaf_nodes(structure[key]))
          return leaf_nodes
      elif isinstance(structure, list):
          leaf_nodes = []
          for item in structure:
              leaf_nodes.extend(get_leaf_nodes(item))
          return leaf_nodes
      return []


  def write_node_ids(data: Any, node_id: int = 0) -> int:
      """Assign sequential zero-padded IDs (0001, 0002...) to all nodes in a tree. Returns next counter."""
      if isinstance(data, dict):
          data['node_id'] = str(node_id).zfill(4)
          node_id += 1
          for key in list(data.keys()):
              if 'nodes' in key:
                  node_id = write_node_ids(data[key], node_id)
      elif isinstance(data, list):
          for item in data:
              node_id = write_node_ids(item, node_id)
      return node_id


  def list_to_tree(data: List[Dict]) -> List[Dict]:
      """Convert flat list with structure codes ('1.2.3') to nested tree."""
      def get_parent_structure(structure):
          if not structure:
              return None
          parts = str(structure).split('.')
          return '.'.join(parts[:-1]) if len(parts) > 1 else None

      nodes = {}
      root_nodes = []

      for item in data:
          structure = item.get('structure')
          node = {
              'title': item.get('title'),
              'start_index': item.get('start_index'),
              'end_index': item.get('end_index'),
              'nodes': []
          }
          nodes[structure] = node
          parent_structure = get_parent_structure(structure)

          if parent_structure and parent_structure in nodes:
              nodes[parent_structure]['nodes'].append(node)
          else:
              root_nodes.append(node)

      def clean_node(node):
          if not node['nodes']:
              del node['nodes']
          else:
              for child in node['nodes']:
                  clean_node(child)
          return node

      return [clean_node(node) for node in root_nodes]


  def remove_tree_fields(data: Any, fields: List[str] = None) -> Any:
      """Recursively remove specified fields from a tree (dict/list)."""
      if fields is None:
          fields = ['text']
      if isinstance(data, dict):
          return {k: remove_tree_fields(v, fields) for k, v in data.items() if k not in fields}
      elif isinstance(data, list):
          return [remove_tree_fields(item, fields) for item in data]
      return data


  def format_tree_structure(structure: Any, order: List[str] = None) -> Any:
      """Reorder fields of each node in a tree according to specified key order."""
      if not order:
          return structure
      if isinstance(structure, dict):
          if 'nodes' in structure:
              structure['nodes'] = format_tree_structure(structure['nodes'], order)
          if not structure.get('nodes'):
              structure.pop('nodes', None)
          return {key: structure[key] for key in order if key in structure}
      elif isinstance(structure, list):
          return [format_tree_structure(item, order) for item in structure]
      return structure


  def create_node_mapping(tree: List[Dict]) -> Dict[str, Dict]:
      """Create flat dict mapping node_id to node for O(1) lookup."""
      mapping = {}
      def _traverse(nodes):
          for node in nodes:
              if node.get('node_id'):
                  mapping[node['node_id']] = node
              if node.get('nodes'):
                  _traverse(node['nodes'])
      _traverse(tree)
      return mapping


  # ── Text / JSON extraction ───────────────────────────────────────────────────


  def extract_json_from_llm(content: str) -> Dict:
      """Extract and parse JSON from LLM responses. Handles ```json blocks, trailing commas, None->null."""
      import json
      try:
          start_idx = content.find("```json")
          if start_idx != -1:
              start_idx += 7
              end_idx = content.rfind("```")
              json_content = content[start_idx:end_idx].strip()
          else:
              json_content = content.strip()

          json_content = json_content.replace('None', 'null')
          json_content = json_content.replace('\n', ' ').replace('\r', ' ')
          json_content = ' '.join(json_content.split())

          return json.loads(json_content)
      except (json.JSONDecodeError, Exception):
          try:
              json_content = json_content.replace(',]', ']').replace(',}', '}')
              return json.loads(json_content)
          except Exception:
              return {}


  def parse_page_range(pages: str) -> List[int]:
      """Parse page range string ('5-7', '3,8', '12') into sorted list of unique ints."""
      result = []
      for part in pages.split(','):
          part = part.strip()
          if '-' in part:
              start, end = int(part.split('-', 1)[0].strip()), int(part.split('-', 1)[1].strip())
              if start > end:
                  raise ValueError(f"Invalid range '{part}': start must be <= end")
              result.extend(range(start, end + 1))
          else:
              result.append(int(part))
      return sorted(set(result))


  # ── Markdown parsing ─────────────────────────────────────────────────────────


  def extract_markdown_headers(markdown_content: str) -> Tuple[List[Dict], List[str]]:
      """Extract all headers (h1-h6) from markdown with line numbers, skipping code blocks."""
      import re
      header_pattern = r'^(#{1,6})\s+(.+)$'
      code_block_pattern = r'^```'
      node_list = []
      lines = markdown_content.split('\n')
      in_code_block = False

      for line_num, line in enumerate(lines, 1):
          stripped_line = line.strip()
          if re.match(code_block_pattern, stripped_line):
              in_code_block = not in_code_block
              continue
          if not stripped_line:
              continue
          if not in_code_block:
              match = re.match(header_pattern, stripped_line)
              if match:
                  level = len(match.group(1))
                  title = match.group(2).strip()
                  node_list.append({'title': title, 'level': level, 'line_num': line_num})

      return node_list, lines


  def build_tree_from_headers(node_list: List[Dict]) -> List[Dict]:
      """Build nested tree from flat list of headers with levels (h1>h2>h3)."""
      if not node_list:
          return []

      stack = []
      root_nodes = []
      node_counter = 1

      for node in node_list:
          current_level = node['level']
          tree_node = {
              'title': node['title'],
              'node_id': str(node_counter).zfill(4),
              'line_num': node['line_num'],
              'nodes': []
          }
          node_counter += 1

          while stack and stack[-1][1] >= current_level:
              stack.pop()

          if not stack:
              root_nodes.append(tree_node)
          else:
              parent_node, _ = stack[-1]
              parent_node['nodes'].append(tree_node)

          stack.append((tree_node, current_level))

      def clean_empty_nodes(nodes):
          for n in nodes:
              if n['nodes']:
                  clean_empty_nodes(n['nodes'])
              else:
                  del n['nodes']
          return nodes

      return clean_empty_nodes(root_nodes)


  # ── Pagination / chunking ────────────────────────────────────────────────────


  def page_list_to_groups(page_contents: List[str], token_lengths: List[int],
                          max_tokens: int = 20000, overlap_pages: int = 1) -> List[str]:
      """Group pages into text chunks respecting token limit with configurable overlap."""
      import math
      num_tokens = sum(token_lengths)

      if num_tokens <= max_tokens:
          return ["".join(page_contents)]

      subsets = []
      current_subset = []
      current_token_count = 0

      expected_parts = math.ceil(num_tokens / max_tokens)
      avg_tokens = math.ceil(((num_tokens / expected_parts) + max_tokens) / 2)

      for i, (page_content, page_tokens) in enumerate(zip(page_contents, token_lengths)):
          if current_token_count + page_tokens > avg_tokens:
              subsets.append(''.join(current_subset))
              overlap_start = max(i - overlap_pages, 0)
              current_subset = list(page_contents[overlap_start:i])
              current_token_count = sum(token_lengths[overlap_start:i])

          current_subset.append(page_content)
          current_token_count += page_tokens

      if current_subset:
          subsets.append(''.join(current_subset))

      return subsets


  def calculate_page_offset(pairs: List[Dict]) -> int:
      """Calculate offset between logical page numbers and physical indices using reference pairs."""
      differences = []
      for pair in pairs:
          try:
              difference = pair['physical_index'] - pair['page']
              differences.append(difference)
          except (KeyError, TypeError):
              continue

      if not differences:
          return 0

      counts: Dict[int, int] = {}
      for diff in differences:
          counts[diff] = counts.get(diff, 0) + 1

      return max(counts.items(), key=lambda x: x[1])[0]


  # ── Text preprocessing ───────────────────────────────────────────────────────


  def preprocess_text(text: str) -> str:
      """Normalize whitespace and newlines in raw text.

      Args:
          text: Raw text to normalize.

      Returns:
          Normalized text with consistent newlines, stripped lines, and no
          excessive blank lines.
      """
      # Normalize line endings: \r\n and \r -> \n
      text = text.replace('\r\n', '\n').replace('\r', '\n')
      # Reduce 3+ consecutive newlines to at most 2
      text = re.sub(r'\n{3,}', '\n\n', text)
      # Strip whitespace from each line
      text = '\n'.join(line.strip() for line in text.split('\n'))
      # Strip globally
      return text.strip()


  def get_text_stats(text: str) -> dict:
      """Compute basic statistics of a text: characters, lines, words.

      Args:
          text: Input text to analyze.

      Returns:
          Dict with keys total_chars (int), total_lines (int), total_words (int).
      """
      return {
          'total_chars': len(text),
          'total_lines': text.count('\n') + 1,
          'total_words': len(text.split()),
      }


  # ── Git URL parsing ──────────────────────────────────────────────────────────

  _DEFAULT_GIT_HOSTS = ["github.com", "gitlab.com"]


  def _sanitize_git_segment(segment: str) -> str:
      """Strip .git suffix then keep only [a-zA-Z0-9_-] chars."""
      if segment.endswith(".git"):
          segment = segment[:-4]
      return re.sub(r"[^a-zA-Z0-9_\-]", "", segment)


  def parse_git_url(url: str, known_hosts: Optional[List[str]] = None) -> Optional[str]:
      """Parse a code-hosting URL and return the 'org/repo' path component.

      Supports HTTPS, HTTP, git://, ssh:// and SSH shorthand (git@host:path).
      Returns None if the URL does not match any known host or is malformed.

      Args:
          url: Repository URL in any supported format.
          known_hosts: List of accepted hostnames. Defaults to github.com and gitlab.com.

      Returns:
          'org/repo' string or None.
      """
      from urllib.parse import urlparse

      hosts = known_hosts if known_hosts is not None else _DEFAULT_GIT_HOSTS
      url = url.strip()

      if url.startswith("git@"):
          # git@github.com:org/repo.git
          rest = url[len("git@"):]
          if ":" not in rest:
              return None
          host, path = rest.split(":", 1)
          if host not in hosts:
              return None
          segments = [s for s in path.split("/") if s]
          if len(segments) < 2:
              return None
          org = _sanitize_git_segment(segments[0])
          repo = _sanitize_git_segment(segments[1])
          if not org or not repo:
              return None
          return f"{org}/{repo}"

      for prefix in ("http://", "https://", "git://", "ssh://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              netloc = parsed.hostname or ""
              if netloc not in hosts:
                  return None
              segments = [s for s in parsed.path.split("/") if s]
              if len(segments) < 2:
                  return None
              org = _sanitize_git_segment(segments[0])
              repo = _sanitize_git_segment(segments[1])
              if not org or not repo:
                  return None
              return f"{org}/{repo}"

      return None


  def is_git_repo_url(url: str, known_hosts: Optional[List[str]] = None) -> bool:
      """Return True only if url points to a clonable git repository.

      Accepts org/repo and org/repo/tree/<ref> paths.
      Rejects paths that navigate to sub-resources (issues, blobs, PRs, etc.).

      Args:
          url: URL to verify.
          known_hosts: Accepted hostnames. Defaults to github.com and gitlab.com.

      Returns:
          True if url is a clonable repository URL.
      """
      from urllib.parse import urlparse

      hosts = known_hosts if known_hosts is not None else _DEFAULT_GIT_HOSTS
      url = url.strip()

      # SSH shorthand — always repo-level if host matches
      if url.startswith("git@"):
          rest = url[len("git@"):]
          if ":" not in rest:
              return False
          host, _ = rest.split(":", 1)
          return host in hosts

      # git:// and ssh:// — always repo-level if host matches
      for prefix in ("ssh://", "git://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              return (parsed.hostname or "") in hosts

      # http:// and https:// — must have exactly org/repo or org/repo/tree/<ref>
      for prefix in ("http://", "https://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              if (parsed.hostname or "") not in hosts:
                  return False
              segments = [s for s in parsed.path.split("/") if s]
              if len(segments) == 2:
                  return True
              if len(segments) == 4 and segments[2] == "tree":
                  return True
              return False

      return False


  def validate_git_ssh_uri(url: str) -> None:
      """Validate a git SSH URI of the form git@host:path.

      Raises ValueError with a descriptive message if the URI is malformed.

      Args:
          url: URI string to validate.

      Raises:
          ValueError: If the URI does not conform to git SSH format.
      """
      if not url.startswith("git@"):
          raise ValueError(f"git SSH URI must start with 'git@', got: {url!r}")
      rest = url[len("git@"):]
      if ":" not in rest:
          raise ValueError(f"git SSH URI must contain ':', got: {url!r}")
      _, path = rest.split(":", 1)
      if not path:
          raise ValueError(f"git SSH URI must have a non-empty path after ':', got: {url!r}")


  # ---------------------------------------------------------------------------
  # Markdown parsing utilities
  # ---------------------------------------------------------------------------


  def extract_frontmatter(content: str) -> Tuple[str, Optional[Dict]]:
      """Extract YAML frontmatter delimited by '---' from the start of a markdown string.

      Args:
          content: Raw markdown string, optionally starting with YAML frontmatter.

      Returns:
          Tuple of (content_without_frontmatter, frontmatter_dict).
          frontmatter_dict is None when no frontmatter is found.
      """
      pattern = re.compile(r'^---\n(.*?)\n---\n', re.DOTALL)
      match = pattern.match(content)
      if not match:
          return content, None

      raw = match.group(1)
      remaining = content[match.end():]

      try:
          import yaml  # type: ignore
          data = yaml.safe_load(raw)
          if not isinstance(data, dict):
              data = None
      except Exception:
          # Fallback: simple key: value parser (no yaml dependency)
          data = {}
          for line in raw.splitlines():
              if ':' in line:
                  key, _, value = line.partition(':')
                  data[key.strip()] = value.strip()

      return remaining, data


  def find_headings(content: str) -> List[Tuple[int, int, str, int]]:
      """Find all markdown headings (# to ######), excluding those inside code blocks,
      HTML comments, and indented blocks.

      Args:
          content: Markdown text to search.

      Returns:
          List of (start_pos, end_pos, title, level) for each heading found.
      """
      excluded: List[Tuple[int, int]] = []

      # Code blocks (triple backtick)
      for m in re.finditer(r'```.*?```', content, re.DOTALL):
          excluded.append((m.start(), m.end()))

      # HTML comments
      for m in re.finditer(r'<!--.*?-->', content, re.DOTALL):
          excluded.append((m.start(), m.end()))

      # Indented blocks (lines starting with 4 spaces or a tab)
      for m in re.finditer(r'^(    |\t).+$', content, re.MULTILINE):
          excluded.append((m.start(), m.end()))

      def is_excluded(pos: int) -> bool:
          return any(start <= pos < end for start, end in excluded)

      results: List[Tuple[int, int, str, int]] = []
      for m in re.finditer(r'^(#{1,6})\s+(.+)$', content, re.MULTILINE):
          # Skip escaped headings (\#)
          before = content[m.start() - 1] if m.start() > 0 else ''
          if before == '\\':
              continue
          if is_excluded(m.start()):
              continue
          level = len(m.group(1))
          title = m.group(2).strip()
          results.append((m.start(), m.end(), title, level))

      return results


  def estimate_token_count(content: str) -> int:
      """Estimate token count without a tokenizer.

      CJK characters count as ~0.7 tokens each; other non-whitespace characters
      count as ~0.3 tokens each.

      Args:
          content: Text to estimate.

      Returns:
          Estimated integer token count.
      """
      cjk = re.findall(r'[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]', content)
      without_cjk = re.sub(r'[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]', '', content)
      others = re.findall(r'\S', without_cjk)
      return int(len(cjk) * 0.7 + len(others) * 0.3)


  def smart_split_content(
      content: str,
      max_tokens: int = 1024,
      max_chars: int = 8000,
  ) -> List[str]:
      """Split large content into parts respecting token and character limits.

      Splits by paragraphs (double newline). If a single paragraph exceeds the
      limit it is force-cut into chunks of max_chars.

      Args:
          content: Text to split.
          max_tokens: Maximum estimated tokens per part.
          max_chars: Maximum characters per part.

      Returns:
          List of string parts.
      """
      paragraphs = content.split('\n\n')
      parts: List[str] = []
      current_parts: List[str] = []
      current_tokens = 0
      current_chars = 0

      def flush() -> None:
          if current_parts:
              parts.append('\n\n'.join(current_parts))
              current_parts.clear()

      for para in paragraphs:
          para_tokens = estimate_token_count(para)
          para_chars = len(para)

          # Single paragraph exceeds limits — force-cut it
          if para_tokens > max_tokens or para_chars > max_chars:
              flush()
              current_tokens = 0
              current_chars = 0
              for i in range(0, len(para), max_chars):
                  parts.append(para[i:i + max_chars])
              continue

          # Would exceed limits if added — flush first
          if (current_tokens + para_tokens > max_tokens or
                  current_chars + para_chars > max_chars):
              flush()
              current_tokens = 0
              current_chars = 0

          current_parts.append(para)
          current_tokens += para_tokens
          current_chars += para_chars

      flush()
      return parts if parts else [content]


  def sanitize_for_path(text: str, max_length: int = 50) -> str:
      """Convert text to a safe string for use in file paths.

      Keeps word characters, CJK characters, spaces and hyphens. Replaces spaces
      with underscores. Truncates with a sha256 suffix if the result exceeds
      max_length.

      Args:
          text: Input text to sanitize.
          max_length: Maximum length of the returned string.

      Returns:
          Safe path-friendly string.
      """
      cleaned = re.sub(
          r'[^\w\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af \-]',
          '',
          text,
      )
      cleaned = cleaned.replace(' ', '_').strip('_')

      if not cleaned:
          return 'section'

      if len(cleaned) <= max_length:
          return cleaned

      suffix = '_' + hashlib.sha256(text.encode()).hexdigest()[:8]
      return cleaned[:max_length - len(suffix)] + suffix

params_schema:
  {"params":[{"name":"xs","desc":"lista de elementos a evaluar"},{"name":"pred","desc":"predicado (funcion que retorna bool) para evaluar cada elemento"}],"output":"booleano: True si al menos un elemento cumple el predicado, False en caso contrario"}


id: build_guide_prompt_py_core
name: build_guide_prompt
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
tags: ["prompt","llm","guide","location","pois","interests","openai","ollama","context"]
uses_functions: []
uses_types: []
returns: []
imports: []
test_file_path: python/functions/core/build_guide_prompt_test.py
file_path: python/functions/core/build_guide_prompt.py
created_at: 2026-04-06T16:24:08Z
updated_at: 2026-04-06T16:24:08Z
props: []
emits: []
variant: []
content_hash: 4fc88d4a5e779f82b617f911546e7adae6899c918b7ffd0ece7485921aac73f9
signature:
  def build_guide_prompt(location: dict, pois: list[dict], interests: list[dict], user_query: str = "", lang: str = "es") -> list[dict]

description:
  Construye un prompt contextual para el LLM combinando ubicación actual, POIs cercanos rankeados e intereses del usuario. Retorna mensajes en formato OpenAI/Ollama.

example:
  ```python
  messages = build_guide_prompt(
      location={
          "display_name": "Puerta del Sol, Madrid",
          "city": "Madrid",
          "neighbourhood": "Sol",
          "street": "Puerta del Sol",
          "country": "España",
      },
      pois=[
          {
              "name": "Museo del Prado",
              "category": "museum",
              "score": 1.0,
              "matched_interests": ["arte"],
              "lat": 40.41,
              "lon": -3.70,
              "tags": {},
          }
      ],
      interests=[{"name": "arte", "keywords": ["museo"], "weight": 1.0}],
      user_query="¿Qué hay interesante por aquí?",
  )
  # [
  #   {"role": "system", "content": "Eres un guía local experto y amigable. El usuario está caminando por Madrid y le interesan: arte..."},
  #   {"role": "user", "content": "📍 Estoy en: Puerta del Sol, Madrid\nBarrio: Sol, Madrid\n\nLugares cercanos que me pueden interesar:\n- Museo del Prado (museum) — relevante para: arte\n\n¿Qué hay interesante por aquí?"}
  # ]
  ```

tests:
  ["con ubicacion completa pois y query","sin pois solo ubicacion","sin query usa default","max 5 pois","lang aparece en system","location sin neighbourhood"]

notes:
  Función pura — no hace I/O, no muta los inputs, determinista.

  El system message establece el rol del LLM como guía local con conocimiento de los intereses del usuario y el idioma de respuesta. Incluye instrucción de ser conciso (3-4 frases) y conversacional.

  El user message combina:
  - Ubicación completa del usuario (solo campos no vacíos)
  - Lista de hasta 5 POIs con nombre, categoría y los intereses que satisfacen
  - La query del usuario o un fallback genérico

  Los campos de `location` vacíos o ausentes se omiten automáticamente del prompt para no generar ruido. Compatible con el formato de salida de `nominatim_reverse_geocode_py_infra` y `match_pois_to_interests`.

code:
  """Construye un prompt contextual para el LLM combinando ubicación, POIs e intereses del usuario."""


  def build_guide_prompt(
      location: dict,
      pois: list[dict],
      interests: list[dict],
      user_query: str = "",
      lang: str = "es",
  ) -> list[dict]:
      """Construye un prompt contextual para el LLM como guía local.

      Combina la ubicación actual del usuario, POIs cercanos rankeados por relevancia
      e intereses del usuario para generar mensajes en formato OpenAI/Ollama.

      Args:
          location: Dict con info de ubicación de nominatim_reverse_geocode.
                    Campos esperados: display_name, street, neighbourhood, city, country, etc.
          pois: Lista de POIs rankeados con campos: name, category, score,
                matched_interests, lat, lon, tags. Ya filtrados y ordenados por score.
          interests: Lista de intereses del usuario. Cada uno con: name, keywords, weight.
          user_query: Pregunta opcional del usuario. Si está vacía, se usa un default.
          lang: Idioma para la respuesta del LLM (ej: "es", "en", "fr").

      Returns:
          Lista de mensajes en formato OpenAI/Ollama:
          [{"role": "system", "content": "..."}, {"role": "user", "content": "..."}]
      """
      # Extraer nombre de intereses para el system prompt
      interest_names = [i.get("name", "") for i in interests if i.get("name")]
      interests_str = ", ".join(interest_names) if interest_names else "lugares de interés general"

      # Extraer ciudad para el system prompt
      city = location.get("city", "") or location.get("town", "") or location.get("village", "") or "la zona"

      # Construir system message
      system_content = (
          f"Eres un guía local experto y amigable. El usuario está caminando por {city} "
          f"y le interesan: {interests_str}.\n"
          "Tu objetivo es informar sobre lo que hay cerca, dar contexto histórico/cultural "
          "relevante a sus intereses, y sugerir qué visitar.\n"
          f"Responde en {lang}. Sé conciso (máximo 3-4 frases) y conversacional, "
          "como si caminaras junto al usuario.\n"
          "Si no hay POIs relevantes, comenta sobre el barrio o zona."
      )

      # Construir user message
      user_parts = []

      # Ubicación
      display_name = location.get("display_name", "")
      if display_name:
          user_parts.append(f"📍 Estoy en: {display_name}")

      neighbourhood = location.get("neighbourhood", "") or location.get("suburb", "")
      city_label = location.get("city", "") or location.get("town", "") or location.get("village", "")
      location_line_parts = [p for p in [neighbourhood, city_label] if p]
      if location_line_parts:
          user_parts.append(f"Barrio: {', '.join(location_line_parts)}")

      # POIs (máximo 5)
      top_pois = pois[:5]
      if top_pois:
          user_parts.append("")
          user_parts.append("Lugares cercanos que me pueden interesar:")
          for poi in top_pois:
              name = poi.get("name", "Sin nombre")
              category = poi.get("category", "")
              matched = poi.get("matched_interests", [])
              matched_str = ", ".join(matched) if matched else ""

              poi_line = f"- {name}"
              if category:
                  poi_line += f" ({category})"
              if matched_str:
                  poi_line += f" — relevante para: {matched_str}"
              user_parts.append(poi_line)

      # Query del usuario
      user_parts.append("")
      if user_query and user_query.strip():
          user_parts.append(user_query.strip())
      else:
          user_parts.append("¿Qué me puedes contar de esta zona?")

      user_content = "\n".join(user_parts)

      return [
          {"role": "system", "content": system_content},
          {"role": "user", "content": user_content},
      ]

params_schema:
  {"params":[{"name":"location","desc":"Dict con info de ubicación de nominatim_reverse_geocode. Campos esperados: display_name, street, neighbourhood, city, country. Los campos vacíos se omiten del prompt."},{"name":"pois","desc":"Lista de POIs rankeados por relevancia (ya filtrados por match_pois_to_interests). Cada POI tiene: name, category, score, matched_interests, lat, lon, tags. Se usan como máximo los primeros 5."},{"name":"interests","desc":"Lista de intereses del usuario. Cada interés tiene: name (str), keywords (list[str]), weight (float). Se usan los names para el system prompt."},{"name":"user_query","desc":"Pregunta opcional del usuario. Si está vacía o no se proporciona, se usa '¿Qué me puedes contar de esta zona?' como default."},{"name":"lang","desc":"Código de idioma para la respuesta del LLM (ej: 'es', 'en', 'fr'). Por defecto 'es'."}],"output":"Lista de dos mensajes en formato OpenAI/Ollama: [{'role': 'system', 'content': '...'}, {'role': 'user', 'content': '...'}]. El system message define el rol de guía local con los intereses y el idioma. El user message incluye la ubicación, los POIs relevantes y la query."}


id: build_tree_from_headers_py_core
name: build_tree_from_headers
kind: function
lang: py
domain: core
version: 1.0.0
purity: pure
signature: def build_tree_from_headers(node_list: list[dict]) -> list[dict]
description: Construye arbol jerarquico anidado desde lista plana de headers markdown con niveles (h1>h2>h3).
tags: ["tree","markdown","headers","hierarchy"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: python/functions/core/core.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: bad37891f2f6e23a10e2928913e29b99183b0d26aa563647ae5e9d162926399c
source_repo: https://github.com/VectifyAI/PageIndex
source_license: MIT
source_file: pageindex/page_index_md.py
example:
  ```python
  headers = [
      {"title": "Intro", "level": 1, "line_num": 1},
      {"title": "Background", "level": 2, "line_num": 5},
      {"title": "Details", "level": 3, "line_num": 10},
      {"title": "Methods", "level": 1, "line_num": 20},
  ]
  tree = build_tree_from_headers(headers)
  # [
  #   {"title": "Intro", "node_id": "0001", "nodes": [
  #     {"title": "Background", "node_id": "0002", "nodes": [
  #       {"title": "Details", "node_id": "0003"}
  #     ]}
  #   ]},
  #   {"title": "Methods", "node_id": "0004"}
  # ]
  ```

notes:
  Funcion pura. Asigna node_id secuencial (0001...) automaticamente. Usa stack para resolver jerarquia por nivel de header.

code:
  """Core functional programming utilities — pure functions for list/collection operations."""

  import hashlib
  import re
  from functools import reduce as _reduce
  from typing import Any, Callable, Dict, List, Optional, Tuple


  def filter_list(xs: list, pred: Callable) -> list:
      """Filter list by predicate. Does not mutate the original."""
      return [x for x in xs if pred(x)]


  def map_list(xs: list, fn: Callable) -> list:
      """Map function over list. Does not mutate the original."""
      return [fn(x) for x in xs]


  def reduce_list(xs: list, initial: Any, fn: Callable) -> Any:
      """Reduce list with accumulator. fn(acc, x) -> acc."""
      return _reduce(fn, xs, initial)


  def flat_map(xs: list, fn: Callable) -> list:
      """Map function over list then flatten one level."""
      result = []
      for x in xs:
          result.extend(fn(x))
      return result


  def flatten(xss: list) -> list:
      """Flatten a list of lists one level."""
      result = []
      for xs in xss:
          result.extend(xs)
      return result


  def chunk(xs: list, size: int) -> list:
      """Split list into chunks of given size. Last chunk may be smaller."""
      if size <= 0:
          return []
      return [xs[i : i + size] for i in range(0, len(xs), size)]


  def take(xs: list, n: int) -> list:
      """Take first n elements from list."""
      return xs[:n]


  def drop(xs: list, n: int) -> list:
      """Drop first n elements from list."""
      return xs[n:]


  def unique(xs: list) -> list:
      """Remove duplicates preserving order. Uses identity for hashable elements."""
      seen = set()
      result = []
      for x in xs:
          if x not in seen:
              seen.add(x)
              result.append(x)
      return result


  def group_by(xs: list, key_fn: Callable) -> Dict:
      """Group elements by key function. Returns dict of key -> list."""
      groups: Dict = {}
      for x in xs:
          k = key_fn(x)
          if k not in groups:
              groups[k] = []
          groups[k].append(x)
      return groups


  def partition(xs: list, pred: Callable) -> Tuple[list, list]:
      """Split list into (matches, non_matches) based on predicate."""
      matches = []
      non_matches = []
      for x in xs:
          if pred(x):
              matches.append(x)
          else:
              non_matches.append(x)
      return (matches, non_matches)


  def find(xs: list, pred: Callable) -> Any:
      """Find first element matching predicate. Returns None if not found."""
      for x in xs:
          if pred(x):
              return x
      return None


  def find_index(xs: list, pred: Callable) -> int:
      """Find index of first element matching predicate. Returns -1 if not found."""
      for i, x in enumerate(xs):
          if pred(x):
              return i
      return -1


  def zip_with(xs: list, ys: list, fn: Callable) -> list:
      """Zip two lists with a combining function. Stops at shorter list."""
      return [fn(x, y) for x, y in zip(xs, ys)]


  def all_of(xs: list, pred: Callable) -> bool:
      """Return True if all elements match predicate."""
      return all(pred(x) for x in xs)


  def any_of(xs: list, pred: Callable) -> bool:
      """Return True if any element matches predicate."""
      return any(pred(x) for x in xs)


  def pipe(value: Any, *fns: Callable) -> Any:
      """Pipe a value through a sequence of functions left-to-right."""
      result = value
      for fn in fns:
          result = fn(result)
      return result


  def compose(*fns: Callable) -> Callable:
      """Compose functions right-to-left. compose(f, g)(x) == f(g(x))."""
      def composed(x: Any) -> Any:
          result = x
          for fn in reversed(fns):
              result = fn(result)
          return result
      return composed


  # ── Tree manipulation ────────────────────────────────────────────────────────


  def flatten_tree(structure: Any) -> List[Dict]:
      """Flatten a hierarchical tree (dict with 'nodes') to a list without children."""
      import copy
      if isinstance(structure, dict):
          node = copy.deepcopy(structure)
          node.pop('nodes', None)
          nodes = [node]
          for key in list(structure.keys()):
              if 'nodes' in key:
                  nodes.extend(flatten_tree(structure[key]))
          return nodes
      elif isinstance(structure, list):
          nodes = []
          for item in structure:
              nodes.extend(flatten_tree(item))
          return nodes
      return []


  def tree_to_flat_list(structure: Any) -> List[Dict]:
      """Convert hierarchical tree to flat list preserving DFS order (keeps internal nodes)."""
      if isinstance(structure, dict):
          nodes = [structure]
          if 'nodes' in structure:
              nodes.extend(tree_to_flat_list(structure['nodes']))
          return nodes
      elif isinstance(structure, list):
          nodes = []
          for item in structure:
              nodes.extend(tree_to_flat_list(item))
          return nodes
      return []


  def get_leaf_nodes(structure: Any) -> List[Dict]:
      """Extract only leaf nodes (no children) from a hierarchical tree."""
      import copy
      if isinstance(structure, dict):
          if not structure.get('nodes'):
              node = copy.deepcopy(structure)
              node.pop('nodes', None)
              return [node]
          leaf_nodes = []
          for key in list(structure.keys()):
              if 'nodes' in key:
                  leaf_nodes.extend(get_leaf_nodes(structure[key]))
          return leaf_nodes
      elif isinstance(structure, list):
          leaf_nodes = []
          for item in structure:
              leaf_nodes.extend(get_leaf_nodes(item))
          return leaf_nodes
      return []


  def write_node_ids(data: Any, node_id: int = 0) -> int:
      """Assign sequential zero-padded IDs (0001, 0002...) to all nodes in a tree. Returns next counter."""
      if isinstance(data, dict):
          data['node_id'] = str(node_id).zfill(4)
          node_id += 1
          for key in list(data.keys()):
              if 'nodes' in key:
                  node_id = write_node_ids(data[key], node_id)
      elif isinstance(data, list):
          for item in data:
              node_id = write_node_ids(item, node_id)
      return node_id


  def list_to_tree(data: List[Dict]) -> List[Dict]:
      """Convert flat list with structure codes ('1.2.3') to nested tree."""
      def get_parent_structure(structure):
          if not structure:
              return None
          parts = str(structure).split('.')
          return '.'.join(parts[:-1]) if len(parts) > 1 else None

      nodes = {}
      root_nodes = []

      for item in data:
          structure = item.get('structure')
          node = {
              'title': item.get('title'),
              'start_index': item.get('start_index'),
              'end_index': item.get('end_index'),
              'nodes': []
          }
          nodes[structure] = node
          parent_structure = get_parent_structure(structure)

          if parent_structure and parent_structure in nodes:
              nodes[parent_structure]['nodes'].append(node)
          else:
              root_nodes.append(node)

      def clean_node(node):
          if not node['nodes']:
              del node['nodes']
          else:
              for child in node['nodes']:
                  clean_node(child)
          return node

      return [clean_node(node) for node in root_nodes]


  def remove_tree_fields(data: Any, fields: List[str] = None) -> Any:
      """Recursively remove specified fields from a tree (dict/list)."""
      if fields is None:
          fields = ['text']
      if isinstance(data, dict):
          return {k: remove_tree_fields(v, fields) for k, v in data.items() if k not in fields}
      elif isinstance(data, list):
          return [remove_tree_fields(item, fields) for item in data]
      return data


  def format_tree_structure(structure: Any, order: List[str] = None) -> Any:
      """Reorder fields of each node in a tree according to specified key order."""
      if not order:
          return structure
      if isinstance(structure, dict):
          if 'nodes' in structure:
              structure['nodes'] = format_tree_structure(structure['nodes'], order)
          if not structure.get('nodes'):
              structure.pop('nodes', None)
          return {key: structure[key] for key in order if key in structure}
      elif isinstance(structure, list):
          return [format_tree_structure(item, order) for item in structure]
      return structure


  def create_node_mapping(tree: List[Dict]) -> Dict[str, Dict]:
      """Create flat dict mapping node_id to node for O(1) lookup."""
      mapping = {}
      def _traverse(nodes):
          for node in nodes:
              if node.get('node_id'):
                  mapping[node['node_id']] = node
              if node.get('nodes'):
                  _traverse(node['nodes'])
      _traverse(tree)
      return mapping


  # ── Text / JSON extraction ───────────────────────────────────────────────────


  def extract_json_from_llm(content: str) -> Dict:
      """Extract and parse JSON from LLM responses. Handles ```json blocks, trailing commas, None->null."""
      import json
      try:
          start_idx = content.find("```json")
          if start_idx != -1:
              start_idx += 7
              end_idx = content.rfind("```")
              json_content = content[start_idx:end_idx].strip()
          else:
              json_content = content.strip()

          json_content = json_content.replace('None', 'null')
          json_content = json_content.replace('\n', ' ').replace('\r', ' ')
          json_content = ' '.join(json_content.split())

          return json.loads(json_content)
      except (json.JSONDecodeError, Exception):
          try:
              json_content = json_content.replace(',]', ']').replace(',}', '}')
              return json.loads(json_content)
          except Exception:
              return {}


  def parse_page_range(pages: str) -> List[int]:
      """Parse page range string ('5-7', '3,8', '12') into sorted list of unique ints."""
      result = []
      for part in pages.split(','):
          part = part.strip()
          if '-' in part:
              start, end = int(part.split('-', 1)[0].strip()), int(part.split('-', 1)[1].strip())
              if start > end:
                  raise ValueError(f"Invalid range '{part}': start must be <= end")
              result.extend(range(start, end + 1))
          else:
              result.append(int(part))
      return sorted(set(result))


  # ── Markdown parsing ─────────────────────────────────────────────────────────


  def extract_markdown_headers(markdown_content: str) -> Tuple[List[Dict], List[str]]:
      """Extract all headers (h1-h6) from markdown with line numbers, skipping code blocks."""
      import re
      header_pattern = r'^(#{1,6})\s+(.+)$'
      code_block_pattern = r'^```'
      node_list = []
      lines = markdown_content.split('\n')
      in_code_block = False

      for line_num, line in enumerate(lines, 1):
          stripped_line = line.strip()
          if re.match(code_block_pattern, stripped_line):
              in_code_block = not in_code_block
              continue
          if not stripped_line:
              continue
          if not in_code_block:
              match = re.match(header_pattern, stripped_line)
              if match:
                  level = len(match.group(1))
                  title = match.group(2).strip()
                  node_list.append({'title': title, 'level': level, 'line_num': line_num})

      return node_list, lines


  def build_tree_from_headers(node_list: List[Dict]) -> List[Dict]:
      """Build nested tree from flat list of headers with levels (h1>h2>h3)."""
      if not node_list:
          return []

      stack = []
      root_nodes = []
      node_counter = 1

      for node in node_list:
          current_level = node['level']
          tree_node = {
              'title': node['title'],
              'node_id': str(node_counter).zfill(4),
              'line_num': node['line_num'],
              'nodes': []
          }
          node_counter += 1

          while stack and stack[-1][1] >= current_level:
              stack.pop()

          if not stack:
              root_nodes.append(tree_node)
          else:
              parent_node, _ = stack[-1]
              parent_node['nodes'].append(tree_node)

          stack.append((tree_node, current_level))

      def clean_empty_nodes(nodes):
          for n in nodes:
              if n['nodes']:
                  clean_empty_nodes(n['nodes'])
              else:
                  del n['nodes']
          return nodes

      return clean_empty_nodes(root_nodes)


  # ── Pagination / chunking ────────────────────────────────────────────────────


  def page_list_to_groups(page_contents: List[str], token_lengths: List[int],
                          max_tokens: int = 20000, overlap_pages: int = 1) -> List[str]:
      """Group pages into text chunks respecting token limit with configurable overlap."""
      import math
      num_tokens = sum(token_lengths)

      if num_tokens <= max_tokens:
          return ["".join(page_contents)]

      subsets = []
      current_subset = []
      current_token_count = 0

      expected_parts = math.ceil(num_tokens / max_tokens)
      avg_tokens = math.ceil(((num_tokens / expected_parts) + max_tokens) / 2)

      for i, (page_content, page_tokens) in enumerate(zip(page_contents, token_lengths)):
          if current_token_count + page_tokens > avg_tokens:
              subsets.append(''.join(current_subset))
              overlap_start = max(i - overlap_pages, 0)
              current_subset = list(page_contents[overlap_start:i])
              current_token_count = sum(token_lengths[overlap_start:i])

          current_subset.append(page_content)
          current_token_count += page_tokens

      if current_subset:
          subsets.append(''.join(current_subset))

      return subsets


  def calculate_page_offset(pairs: List[Dict]) -> int:
      """Calculate offset between logical page numbers and physical indices using reference pairs."""
      differences = []
      for pair in pairs:
          try:
              difference = pair['physical_index'] - pair['page']
              differences.append(difference)
          except (KeyError, TypeError):
              continue

      if not differences:
          return 0

      counts: Dict[int, int] = {}
      for diff in differences:
          counts[diff] = counts.get(diff, 0) + 1

      return max(counts.items(), key=lambda x: x[1])[0]


  # ── Text preprocessing ───────────────────────────────────────────────────────


  def preprocess_text(text: str) -> str:
      """Normalize whitespace and newlines in raw text.

      Args:
          text: Raw text to normalize.

      Returns:
          Normalized text with consistent newlines, stripped lines, and no
          excessive blank lines.
      """
      # Normalize line endings: \r\n and \r -> \n
      text = text.replace('\r\n', '\n').replace('\r', '\n')
      # Reduce 3+ consecutive newlines to at most 2
      text = re.sub(r'\n{3,}', '\n\n', text)
      # Strip whitespace from each line
      text = '\n'.join(line.strip() for line in text.split('\n'))
      # Strip globally
      return text.strip()


  def get_text_stats(text: str) -> dict:
      """Compute basic statistics of a text: characters, lines, words.

      Args:
          text: Input text to analyze.

      Returns:
          Dict with keys total_chars (int), total_lines (int), total_words (int).
      """
      return {
          'total_chars': len(text),
          'total_lines': text.count('\n') + 1,
          'total_words': len(text.split()),
      }


  # ── Git URL parsing ──────────────────────────────────────────────────────────

  _DEFAULT_GIT_HOSTS = ["github.com", "gitlab.com"]


  def _sanitize_git_segment(segment: str) -> str:
      """Strip .git suffix then keep only [a-zA-Z0-9_-] chars."""
      if segment.endswith(".git"):
          segment = segment[:-4]
      return re.sub(r"[^a-zA-Z0-9_\-]", "", segment)


  def parse_git_url(url: str, known_hosts: Optional[List[str]] = None) -> Optional[str]:
      """Parse a code-hosting URL and return the 'org/repo' path component.

      Supports HTTPS, HTTP, git://, ssh:// and SSH shorthand (git@host:path).
      Returns None if the URL does not match any known host or is malformed.

      Args:
          url: Repository URL in any supported format.
          known_hosts: List of accepted hostnames. Defaults to github.com and gitlab.com.

      Returns:
          'org/repo' string or None.
      """
      from urllib.parse import urlparse

      hosts = known_hosts if known_hosts is not None else _DEFAULT_GIT_HOSTS
      url = url.strip()

      if url.startswith("git@"):
          # git@github.com:org/repo.git
          rest = url[len("git@"):]
          if ":" not in rest:
              return None
          host, path = rest.split(":", 1)
          if host not in hosts:
              return None
          segments = [s for s in path.split("/") if s]
          if len(segments) < 2:
              return None
          org = _sanitize_git_segment(segments[0])
          repo = _sanitize_git_segment(segments[1])
          if not org or not repo:
              return None
          return f"{org}/{repo}"

      for prefix in ("http://", "https://", "git://", "ssh://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              netloc = parsed.hostname or ""
              if netloc not in hosts:
                  return None
              segments = [s for s in parsed.path.split("/") if s]
              if len(segments) < 2:
                  return None
              org = _sanitize_git_segment(segments[0])
              repo = _sanitize_git_segment(segments[1])
              if not org or not repo:
                  return None
              return f"{org}/{repo}"

      return None


  def is_git_repo_url(url: str, known_hosts: Optional[List[str]] = None) -> bool:
      """Return True only if url points to a clonable git repository.

      Accepts org/repo and org/repo/tree/<ref> paths.
      Rejects paths that navigate to sub-resources (issues, blobs, PRs, etc.).

      Args:
          url: URL to verify.
          known_hosts: Accepted hostnames. Defaults to github.com and gitlab.com.

      Returns:
          True if url is a clonable repository URL.
      """
      from urllib.parse import urlparse

      hosts = known_hosts if known_hosts is not None else _DEFAULT_GIT_HOSTS
      url = url.strip()

      # SSH shorthand — always repo-level if host matches
      if url.startswith("git@"):
          rest = url[len("git@"):]
          if ":" not in rest:
              return False
          host, _ = rest.split(":", 1)
          return host in hosts

      # git:// and ssh:// — always repo-level if host matches
      for prefix in ("ssh://", "git://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              return (parsed.hostname or "") in hosts

      # http:// and https:// — must have exactly org/repo or org/repo/tree/<ref>
      for prefix in ("http://", "https://"):
          if url.startswith(prefix):
              parsed = urlparse(url)
              if (parsed.hostname or "") not in hosts:
                  return False
              segments = [s for s in parsed.path.split("/") if s]
              if len(segments) == 2:
                  return True
              if len(segments) == 4 and segments[2] == "tree":
                  return True
              return False

      return False


  def validate_git_ssh_uri(url: str) -> None:
      """Validate a git SSH URI of the form git@host:path.

      Raises ValueError with a descriptive message if the URI is malformed.

      Args:
          url: URI string to validate.

      Raises:
          ValueError: If the URI does not conform to git SSH format.
      """
      if not url.startswith("git@"):
          raise ValueError(f"git SSH URI must start with 'git@', got: {url!r}")
      rest = url[len("git@"):]
      if ":" not in rest:
          raise ValueError(f"git SSH URI must contain ':', got: {url!r}")
      _, path = rest.split(":", 1)
      if not path:
          raise ValueError(f"git SSH URI must have a non-empty path after ':', got: {url!r}")


  # ---------------------------------------------------------------------------
  # Markdown parsing utilities
  # ---------------------------------------------------------------------------


  def extract_frontmatter(content: str) -> Tuple[str, Optional[Dict]]:
      """Extract YAML frontmatter delimited by '---' from the start of a markdown string.

      Args:
          content: Raw markdown string, optionally starting with YAML frontmatter.

      Returns:
          Tuple of (content_without_frontmatter, frontmatter_dict).
          frontmatter_dict is None when no frontmatter is found.
      """
      pattern = re.compile(r'^---\n(.*?)\n---\n', re.DOTALL)
      match = pattern.match(content)
      if not match:
          return content, None

      raw = match.group(1)
      remaining = content[match.end():]

      try:
          import yaml  # type: ignore
          data = yaml.safe_load(raw)
          if not isinstance(data, dict):
              data = None
      except Exception:
          # Fallback: simple key: value parser (no yaml dependency)
          data = {}
          for line in raw.splitlines():
              if ':' in line:
                  key, _, value = line.partition(':')
                  data[key.strip()] = value.strip()

      return remaining, data


  def find_headings(content: str) -> List[Tuple[int, int, str, int]]:
      """Find all markdown headings (# to ######), excluding those inside code blocks,
      HTML comments, and indented blocks.

      Args:
          content: Markdown text to search.

      Returns:
          List of (start_pos, end_pos, title, level) for each heading found.
      """
      excluded: List[Tuple[int, int]] = []

      # Code blocks (triple backtick)
      for m in re.finditer(r'```.*?```', content, re.DOTALL):
          excluded.append((m.start(), m.end()))

      # HTML comments
      for m in re.finditer(r'<!--.*?-->', content, re.DOTALL):
          excluded.append((m.start(), m.end()))

      # Indented blocks (lines starting with 4 spaces or a tab)
      for m in re.finditer(r'^(    |\t).+$', content, re.MULTILINE):
          excluded.append((m.start(), m.end()))

      def is_excluded(pos: int) -> bool:
          return any(start <= pos < end for start, end in excluded)

      results: List[Tuple[int, int, str, int]] = []
      for m in re.finditer(r'^(#{1,6})\s+(.+)$', content, re.MULTILINE):
          # Skip escaped headings (\#)
          before = content[m.start() - 1] if m.start() > 0 else ''
          if before == '\\':
              continue
          if is_excluded(m.start()):
              continue
          level = len(m.group(1))
          title = m.group(2).strip()
          results.append((m.start(), m.end(), title, level))

      return results


  def estimate_token_count(content: str) -> int:
      """Estimate token count without a tokenizer.

      CJK characters count as ~0.7 tokens each; other non-whitespace characters
      count as ~0.3 tokens each.

      Args:
          content: Text to estimate.

      Returns:
          Estimated integer token count.
      """
      cjk = re.findall(r'[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]', content)
      without_cjk = re.sub(r'[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]', '', content)
      others = re.findall(r'\S', without_cjk)
      return int(len(cjk) * 0.7 + len(others) * 0.3)


  def smart_split_content(
      content: str,
      max_tokens: int = 1024,
      max_chars: int = 8000,
  ) -> List[str]:
      """Split large content into parts respecting token and character limits.

      Splits by paragraphs (double newline). If a single paragraph exceeds the
      limit it is force-cut into chunks of max_chars.

      Args:
          content: Text to split.
          max_tokens: Maximum estimated tokens per part.
          max_chars: Maximum characters per part.

      Returns:
          List of string parts.
      """
      paragraphs = content.split('\n\n')
      parts: List[str] = []
      current_parts: List[str] = []
      current_tokens = 0
      current_chars = 0

      def flush() -> None:
          if current_parts:
              parts.append('\n\n'.join(current_parts))
              current_parts.clear()

      for para in paragraphs:
          para_tokens = estimate_token_count(para)
          para_chars = len(para)

          # Single paragraph exceeds limits — force-cut it
          if para_tokens > max_tokens or para_chars > max_chars:
              flush()
              current_tokens = 0
              current_chars = 0
              for i in range(0, len(para), max_chars):
                  parts.append(para[i:i + max_chars])
              continue

          # Would exceed limits if added — flush first
          if (current_tokens + para_tokens > max_tokens or
                  current_chars + para_chars > max_chars):
              flush()
              current_tokens = 0
              current_chars = 0

          current_parts.append(para)
          current_tokens += para_tokens
          current_chars += para_chars

      flush()
      return parts if parts else [content]


  def sanitize_for_path(text: str, max_length: int = 50) -> str:
      """Convert text to a safe string for use in file paths.

      Keeps word characters, CJK characters, spaces and hyphens. Replaces spaces
      with underscores. Truncates with a sha256 suffix if the result exceeds
      max_length.

      Args:
          text: Input text to sanitize.
          max_length: Maximum length of the returned string.

      Returns:
          Safe path-friendly string.
      """
      cleaned = re.sub(
          r'[^\w\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af \-]',
          '',
          text,
      )
      cleaned = cleaned.replace(' ', '_').strip('_')

      if not cleaned:
          return 'section'

      if len(cleaned) <= max_length:
          return cleaned

      suffix = '_' + hashlib.sha256(text.encode()).hexdigest()[:8]
      return cleaned[:max_length - len(suffix)] + suffix

params_schema:
  {"params":[{"name":"node_list","desc":"lista plana de dicts con campos title, level y opcionalmente line_num"}],"output":"lista de dicts que forman arbol jerarquico anidado segun niveles"}


