Skill: fdovarela/mercado-publico-chilecompra (version 1.0.1)

--- name: mercado-publico-chilecompra description: Operate and assist across Chile's Mercado Público / ChileCompra supplier workflows using both the public API and the private supplier portal. Use when searching, listing, monitoring, or reporting on licitaciones, órdenes de compra, buyers, suppliers, and opportunities via API, or when navigating the authenticated portal to review licitaciones, órdenes de compra, cotizaciones, compra ágil, trato directo, reclamos, and portal-specific states, actions, errors, disabled buttons, broken routes, or iframe-driven workflows. ClaveÚnica + email OTP login may require manual fallback or reuse of an already-authenticated session when the federated broker is fragile. metadata: {"openclaw":{"primaryEnv":"MERCADO_PUBLICO_API_TICKET","requires":{"env":["MERCADO_PUBLICO_API_TICKET"]}}} --- # Mercado Público Operate the private Mercado Público supplier portal as a cautious copiloto operativo. ## Quick start 1. Start from the authenticated supplier shell when possible: - `https://www.mercadopublico.cl/Portal/Modules/Menu/Menu.aspx` 2. Treat the site as **hybrid**: - legacy modules often load inside iframe `fraDetalle` - newer modules may open in separate routes or embedded apps 3. Prefer **read / prepare / explain** first. 4. Require explicit confirmation before any action that changes state. ## Core workflow ### 1. Establish session - Use ClaveÚnica flow when needed. - Expect email OTP. - Expect entity selection after login. - If session already exists, reuse it instead of reauthenticating. - Treat full broker automation as potentially fragile; prefer an already-authenticated session or manual fallback when the federated flow starts expiring or rejecting automation. For details, read in this order: - `references/login-state-machine.md` (siempre, para decidir estado/transición e intervención humana) - `references/auth-y-sesion.md` (flujo base y señales de sesión) - `references/acceso-a-correo-otp.md` (requisitos generales de acceso a correo OTP) - `references/otp-provider-contract.md` (contrato esperado del proveedor OTP) ### 2. Route correctly - Prefer entering through `Menu.aspx`. - Inspect whether the target module lives in `fraDetalle`. - If a direct URL returns 404, retry from the menu/iframe context before concluding the module is broken. - Distinguish between: - legacy ASP.NET pages with postbacks - newer modules with separate routes / richer UI For details, read: - `references/arquitectura-y-ruteo.md` ### 3. Choose the task path - Apply the business rule **API first, portal after**. - Prefer the public API when it answers the question with enough fidelity. - Use the private portal only when the API does not cover the case, when provider-specific authenticated context matters, or when a real portal action/review is required. - Leer `references/runbooks.md` para ejecutar flujo operativo mínimo (buscar licitación, revisar ofertabilidad, revisar OC, preparar cotización, diagnosticar portal). - **Licitaciones** → search, review detail, questions, attachments, offer readiness - **Órdenes de compra** → search, inspect detail, identify allowed actions, XML/attachments/payments - **Cotizaciones / Trato Directo / Compra Ágil** → inspect and prepare response flows up to pre-confirmation - **Reclamos** → locate correct entry point, identify whether flow stays in portal or redirects to help center - **Gestión / reportería** → summarize pending items, counts, and dashboards - **Diagnóstico** → explain 404s, disabled buttons, null-reference errors, CAPTCHA, or context-sensitive failures Read only the relevant reference file(s): - `references/api-publica.md` (always first for discovery/reporting/search use cases and API-vs-portal decisions) - `references/licitaciones.md` - `references/ordenes-de-compra.md` - `references/cotizaciones-trato-directo-y-compra-agil.md` - `references/reclamos-y-ayuda.md` - `references/gestion-y-reporteria.md` - `references/diagnostico-y-guardrails.md` ## Operating rules - Prefer safe navigation from the logged-in shell over guessing deep links. - Explain *why* an action is blocked when possible (estado hábil, role, timing, state, broken module). - Separate: - **leer** - **preparar** - **confirmar y enviar** - Stop and ask for confirmation before any irreversible or state-changing action. ## Mandatory guardrails Always require explicit confirmation before: - sending an offer - sending a quotation - accepting or rejecting an order - requesting cancellation - submitting a complaint - performing any action that changes procurement state, contractual state, or payment-related state Never assume a visible button is safe to click just because it is present. If the portal shows 404, `NullReferenceException`, a disabled action, or unexpected redirects: - diagnose first - explain the likely cause - propose the next safe step ## Resources Use these references as needed: - Login/auth state transitions: `references/login-state-machine.md` - Auth/session baseline: `references/auth-y-sesion.md` - Generic email-access requirements for OTP: `references/acceso-a-correo-otp.md` - OTP provider contract: `references/otp-provider-contract.md` - Operational runbooks (minimum executable flows): `references/runbooks.md` - Portal architecture and routing: `references/arquitectura-y-ruteo.md` - Licitaciones: `references/licitaciones.md` - Orders: `references/ordenes-de-compra.md` - Quotations / direct purchase / agile purchase: `references/cotizaciones-trato-directo-y-compra-agil.md` - Complaints/help center: `references/reclamos-y-ayuda.md` - Dashboards/reporting: `references/gestion-y-reporteria.md` - Public API: `references/api-publica.md` - Diagnostics and safety rules: `references/diagnostico-y-guardrails.md` Operational helpers: - `scripts/mercado_publico_api.py` (API pública, read-only; ticket en `MERCADO_PUBLICO_API_TICKET`, `--summary` estable y caché TTL opcional)

path: references/acceso-a-correo-otp.md
content: # Acceso al correo para OTP (genérico)

## Objetivo

Resolver el OTP de ClaveÚnica sin depender necesariamente de ingreso manual del usuario.

## Prerequisitos del usuario

Antes de usar el login autenticado con OTP, el usuario debe garantizar:

1. que el agente **puede acceder** al correo donde llegarán los mensajes OTP
2. que el usuario **indique cuál será ese correo**
3. que el usuario **defina el mecanismo** por el cual el agente accederá a ese inbox
4. que el usuario **valide previamente** que ese acceso funciona en la práctica

Sin esas condiciones, el flujo debe degradar a OTP manual.

## Opciones válidas de acceso al correo

El skill debe tratar el acceso al correo de forma **genérica**. Algunas implementaciones posibles:

- integración de OpenClaw con Google Workspace / Gmail
- Microsoft Graph / Outlook / Exchange
- otro mecanismo confiable que permita al agente listar y leer correos recientes

La versión pública de esta skill no empaqueta un lector de inbox concreto. Cualquier automatización de OTP por correo debe resolverse mediante una integración externa, explícitamente elegida por el usuario y validada antes de usarla.

## Qué debe pedir la skill al usuario

Pedir explícitamente al usuario:

- qué correo se usará para recibir OTP de ClaveÚnica
- por qué mecanismo podrá acceder el agente a ese correo
- que valide antes que el agente realmente puede leer correos entrantes en ese buzón
- que confirme si desea OTP automático o fallback manual

Ver también:
- `references/otp-provider-contract.md`

## Flujo recomendado

1. Iniciar login ClaveÚnica.
2. Cuando el sitio avise que envió OTP, abrir ventana de espera de hasta 5 minutos.
3. Escuchar correos nuevos en el buzón indicado por el usuario.
4. Filtrar por remitente/asunto/contexto esperados.
5. Extraer el OTP.
6. Usarlo inmediatamente antes de expiración.
7. Si no llega, pedir regeneración o pasar a ingreso manual.

## Criterios prácticos base observados

- remitente esperado: `[REDACTED_SECRET]`
- asunto esperado: contiene `ClaveÚnica - Código de validación de autenticación`
- vigencia: ~5 minutos
- OTP observado: alfanumérico en mayúsculas, 6 caracteres
- regex base útil: `\b[A-Z0-9]{6}\b`

## Guardrails

- no persistir OTP en memoria de largo plazo ni archivos
- no asumir que cualquier correo parecido sirve; validar remitente, asunto y timing
- si llegan varios correos, usar el más reciente recibido después del inicio del login actual
- si el acceso al correo no está confirmado, no prometer automatización completa del login


path: references/api-publica.md
content: # API pública de Mercado Público

## Rol dentro del skill

La skill declara explícitamente en metadata el requisito `MERCADO_PUBLICO_API_TICKET`.

Usar la API pública como capa **read-only** para:
- discovery de licitaciones y órdenes de compra
- búsquedas por código, fecha, estado, organismo y proveedor
- enriquecimiento previo antes de entrar al portal privado
- reportería, monitoreo y alertas

No usarla como reemplazo del portal privado para acciones transaccionales.

## Lo que documenta oficialmente

La documentación pública indica soporte para:

### Licitaciones
- por código
- diarias (día actual)
- por fecha (`ddmmaaaa`)
- por estado
- por fecha + estado
- por código de organismo comprador
- por código de proveedor

### Órdenes de compra
- por código
- diarias (día actual)
- por fecha (`ddmmaaaa`)
- por estado
- por fecha + estado
- por código de organismo comprador
- por código de proveedor

### Empresas
- `BuscarProveedor` por RUT
- `BuscarComprador` para obtener organismos compradores y sus códigos internos

## Endpoints documentados

### Empresas
Buscar proveedor por RUT:
`https://api.mercadopublico.cl/servicios/v1/Publico/Empresas/BuscarProveedor?rutempresaproveedor=<RUT>&ticket=<TICKET>`

Buscar compradores:
`https://api.mercadopublico.cl/servicios/v1/Publico/Empresas/BuscarComprador?ticket=<TICKET>`

### Licitaciones
JSON:
`https://api.mercadopublico.cl/servicios/v1/publico/licitaciones.json?...&ticket=<TICKET>`

Ejemplos documentados:
- por código: `?codigo=1509-5-L114`
- por fecha: `?fecha=02022014`
- por estado activo: `?estado=activas`
- por fecha + estado: `?fecha=02022014&estado=adjudicada`
- por organismo: `?fecha=02022014&CodigoOrganismo=6945`
- por proveedor: `?fecha=02022014&CodigoProveedor=17793`

### Órdenes de compra
JSON:
`https://api.mercadopublico.cl/servicios/v1/publico/ordenesdecompra.json?...&ticket=<TICKET>`

Ejemplos documentados:
- por código: `?codigo=2097-241-SE14`
- por fecha: `?fecha=02022014`
- por estado del día actual: `?estado=todos`
- por fecha + estado: `?fecha=02022014&estado=aceptada`
- por organismo: `?fecha=02022014&CodigoOrganismo=6945`
- por proveedor: `?fecha=02022014&CodigoProveedor=17793`

## Estados documentados

### Licitaciones
- Publicada = `5`
- Cerrada = `6`
- Desierta = `7`
- Adjudicada = `8`
- Revocada = `18`
- Suspendida = `19`
- además existe atajo textual `estado=activas`

### Órdenes de compra
- Enviada a Proveedor = `4`
- En proceso = `5`
- Aceptada = `6`
- Cancelada = `9`
- Recepción Conforme = `12`
- Pendiente de Recepcionar = `13`
- Recepcionada Parcialmente = `14`
- Recepción Conforme Incompleta = `15`

Estados textuales documentados para OC:
- `enviadaproveedor`
- `aceptada`
- `cancelada`
- `recepcionconforme`
- `pendienterecepcion`
- `recepcionaceptadacialmente` *(la documentación parece tener typo)*
- `recepecionconformeincompleta` *(la documentación parece tener typo)*
- `todos`

## Campos útiles observados/documentados

### Licitaciones
- `CodigoExterno`
- `Nombre`
- `CodigoEstado` / `Estado`
- `Descripcion`
- `FechaCierre`, `FechaPublicacion`, `FechaAdjudicacion`
- datos de `Comprador`
- `MontoEstimado`, `Moneda`
- `CantidadReclamos`
- `Items` con producto/categoría/UNSPSC
- datos de `Adjudicacion`, incluyendo `UrlActa`

### Órdenes de compra
- `Codigo`
- `Nombre`
- `CodigoEstado` / `EstadoProveedor`
- `CodigoLicitacion`
- fechas de envío/aceptación/cancelación
- totales (`TotalNeto`, `Impuestos`, `Total`)
- `Comprador`
- `Proveedor`
- `Items`

## Hallazgos prácticos de integración

### 1. La API sirve muy bien para capa pública/read-only
Es ideal para:
- prefiltrar oportunidades
- lookup de organismo/proveedor
- reportería y watchlists
- enriquecer contexto antes de entrar al portal privado

### 1.1 Caché local
- el helper API solo escribe caché local si se activa `--cache-ttl`
- por defecto el caché está desactivado (`--cache-ttl 0`)
- el caché se guarda en una ruta local del skill y no se transmite fuera del host

### 2. La documentación es mejor que la portada
La home principal aporta poco; las páginas útiles son:
- `modules/api.aspx`
- `modules/ejemplo_08.aspx`
- `modules/ejemplo_10.aspx`
- `modules/Licitacion.aspx`
- `modules/OrdenCompra.aspx`

### 3. Hay señales de rate limiting o control de concurrencia
Se observó respuesta de error:
- `{"Codigo":10500,"Mensaje":"Lo sentimos. Hemos detectado que existen peticiones simultáneas."}`

Por diseño del skill:
- serializar llamadas
- evitar bursts
- respetar pausas cortas entre requests
- cachear resultados cuando sea razonable

### 4. No asumir que el ticket de prueba sirve bien para todo
El ticket de prueba funciona al menos para lookup de empresas, pero no confiar en él para flujos de producción.

## Regla de negocio central

**API primero, portal después.**

Usar la API pública cuando resuelva el caso con suficiente fidelidad. Reservar el portal privado para los casos donde la API no alcance, falte contexto autenticado del proveedor o exista una acción/revisión propia del portal.

## Matriz de decisión API vs portal

### Preferir API cuando el caso de uso sea:
- discovery de oportunidades
- búsqueda/listado por código, fecha, estado, comprador o proveedor
- lookup de proveedor o comprador
- reportería y monitoreo
- prefiltrado antes de revisar casos concretos en el portal
- enriquecimiento de contexto read-only

### Preferir portal privado cuando el caso de uso sea:
- revisar el estado real de una cuenta o entidad autenticada
- ver preguntas/respuestas, adjuntos, botones y restricciones visibles de una pantalla real
- validar ofertabilidad con contexto del proveedor
- revisar órdenes de compra desde la experiencia real del proveedor
- preparar cotización/oferta/reclamo hasta preconfirmación
- ejecutar una acción transaccional o con cambio de estado

## Regla práctica de decisión

### Si la pregunta del usuario es de tipo:
- "buscar"
- "listar"
- "monitorear"
- "reportar"
- "identificar oportunidades"

→ empezar por **API**.

### Si la pregunta del usuario es de tipo:
- "revisar mi caso real"
- "ver qué acciones tengo disponibles"
- "entender esta pantalla"
- "ejecutar/preparar una acción"

→ usar **portal privado**.

### Si la pregunta mezcla ambos planos

→ usar **API para reducir el espacio de búsqueda** y luego **portal para validar el caso concreto**.

## Guardrails técnicos

- Mantener el ticket fuera de archivos versionados.
- Evitar paralelismo agresivo.
- Tratar respuestas 500/10500 como posible límite o política anti-concurrencia, no como “sin datos”.
- Si la API falla, explicar eso y ofrecer fallback por portal o reintento seguro.

## Helper CLI

Script incluido en la skill:
- `scripts/mercado_publico_api.py`

### Configuración

Requiere ticket por variable de entorno (no hardcodear):

```bash
export MERCADO_PUBLICO_API_TICKET="TU_TICKET"
```

Si la variable no existe, el script falla con mensaje útil y código de salida `2`.

### Operaciones soportadas

```bash
# Empresas
python3 scripts/mercado_publico_api.py buscar-proveedor --rut 76123456-7
python3 scripts/mercado_publico_api.py buscar-comprador
python3 scripts/mercado_publico_api.py buscar-comprador --filter salud

# Licitaciones
python3 scripts/mercado_publico_api.py licitaciones --codigo 1509-5-L114
python3 scripts/mercado_publico_api.py licitaciones --fecha 02022014
python3 scripts/mercado_publico_api.py licitaciones --fecha 02022014 --estado activas
python3 scripts/mercado_publico_api.py licitaciones --fecha 02022014 --codigo-organismo 6945
python3 scripts/mercado_publico_api.py licitaciones --fecha 02022014 --codigo-proveedor 17793

# Órdenes de compra
python3 scripts/mercado_publico_api.py ordenes --codigo 2097-241-SE14
python3 scripts/mercado_publico_api.py ordenes --fecha 02022014
python3 scripts/mercado_publico_api.py ordenes --fecha 02022014 --estado aceptada
python3 scripts/mercado_publico_api.py ordenes --fecha 02022014 --codigo-organismo 6945
python3 scripts/mercado_publico_api.py ordenes --fecha 02022014 --codigo-proveedor 17793
```

### Flags nuevas y comportamiento (v1.1)

- `--cache-ttl <segundos>`: activa caché local TTL (default `0`, sin caché).
- `--cache-dir <path>`: ruta alternativa para caché. Default: `skills/mercado-publico-chilecompra/run/api-cache`.
- `--summary`: salida breve estable en formato `clave=valor` (útil para monitoreo ligero).

Notas de caché:
- La clave de caché se calcula desde endpoint + query normalizada (hash SHA-256).
- Si hay hit vigente (`age <= ttl`), no llama a la API.
- Si el cache está vencido, hace request nuevo y reemplaza cache.
- No hay fallback automático a cache vencido cuando falla la API (comportamiento explícito y simple).

### Summary estable (`--summary`)

La salida es intencionalmente corta y consistente:
- incluye siempre `summary_version`, `command`, `source`, `count`
- cuando caché está activa: `cache_ttl_seconds`, `cache_age_seconds`
- agrega campos de consulta + primer registro para cada comando

Cobertura mínima v1.1:
- `buscar-proveedor`
- `buscar-comprador`
- `licitaciones --codigo`
- `licitaciones --fecha ...`
- `ordenes --codigo`
- `ordenes --fecha ...`

### Ejemplos concretos

```bash
# 1) Consultar licitación por código con caché por 10 minutos y salida estable
python3 scripts/mercado_publico_api.py \
  --cache-ttl 600 --summary \
  licitaciones --codigo 1509-5-L114

# 2) Consultar órdenes por fecha + estado usando directorio de caché custom
python3 scripts/mercado_publico_api.py \
  --cache-ttl 300 --cache-dir ./tmp/mp-cache --summary \
  ordenes --fecha 02022014 --estado aceptada

# 3) Buscar comprador filtrado localmente y resumen corto
python3 scripts/mercado_publico_api.py \
  --summary buscar-comprador --filter salud
```

### Comportamiento de robustez

- Llamadas seriales (1 request por ejecución).
- Timeout configurable (`--timeout`, default 15s).
- Retries suaves con backoff corto para errores transitorios (`--max-retries`, `--backoff`).
- Manejo explícito de `Codigo 10500` / “peticiones simultáneas”.
- Salida JSON legible por defecto (`pretty`).
- Modo `--summary` estable para lectura humana y automatización simple.


path: references/arquitectura-y-ruteo.md
content: # Arquitectura y ruteo del portal

## Modelo mental

El portal es híbrido:
- shell legacy en `Menu.aspx`
- muchos módulos cargados en iframe `fraDetalle`
- otros módulos más nuevos viven en rutas separadas o integradas parcialmente

## Regla principal

Preferir partir desde:
- `https://www.mercadopublico.cl/Portal/Modules/Menu/Menu.aspx`

Antes de concluir que un módulo está roto:
1. intentar desde el menú
2. revisar si debe cargarse en `fraDetalle`
3. recién después probar/descartar una URL directa

## Patrón de navegación recomendado

1. Abrir o enfocar `Menu.aspx` autenticado.
2. Inspeccionar `iframe#fraDetalle`.
3. Cargar el módulo en `fraDetalle` cuando corresponda.
4. Si el módulo abre en ruta externa/moderna, seguir ese flujo y verificar que mantiene sesión.

## Problemas observados

- Algunas rutas del menú abiertas directamente devuelven 404.
- Algunos módulos legacy dependen de postbacks y contexto interno.
- Algunos módulos nuevos pueden mostrar errores servidor si entran sin el estado esperado.
- Puede haber CAPTCHA o validaciones externas en ciertos flujos.
- Abrir ciertos módulos en pestañas nuevas o fuera del shell autenticado puede redirigir a la home pública, incluso si la sesión parecía viva en `Menu.aspx`.

## Señales útiles

- 404 directo no siempre significa módulo inexistente; puede ser problema de contexto.
- `NullReferenceException` o errores .NET pueden indicar bug real del portal o estado incompleto.
- Si `fraDetalle` cambia y el shell permanece estable, normalmente el flujo correcto es interno.


path: references/auth-y-sesion.md
content: # Auth y sesión

## Flujo base observado

1. Entrar a Mercado Público.
2. Redirección a ClaveÚnica.
3. Ingresar credenciales.
4. Ingresar OTP enviado por correo.
5. Seleccionar entidad/empresa.
6. Entrar al shell privado proveedor (`Menu.aspx`).

## Arquitectura recomendada (acceso genérico al correo)

Usar un esquema de correo **asignado al agente** para OTP:

1. El usuario define un buzón/cuenta para validaciones de ClaveÚnica (dedicado o controlado).
2. El usuario valida que el agente puede acceder a ese correo por un mecanismo confiable.
3. El usuario indica explícitamente cuál será ese correo.
4. En el login, cuando ClaveÚnica dispare OTP, el agente abre una ventana de espera de hasta 5 minutos.
5. El agente busca correo OTP por remitente/asunto y extrae código.
6. Usa el OTP inmediatamente (vigencia corta).

Implementaciones posibles:
- integración de OpenClaw con Google
- Microsoft Graph
- otra integración de lectura de correo elegida y validada por el usuario

Detalle operativo en:
- `references/acceso-a-correo-otp.md`
- `references/otp-provider-contract.md`

Nota:
- La versión pública de esta skill no empaqueta implementaciones concretas de lectura de inbox. El OTP automático, si se usa, debe resolverse mediante una integración externa elegida y autorizada por el usuario. Si no existe esa integración, usar fallback manual.

## Reglas operativas de sesión

- Reutilizar sesión activa si existe.
- No reloguear sin necesidad.
- Detectar selector de entidad y no asumir empresa única.
- Confirmar salida final en shell privado proveedor (no home pública).
- Si el broker de ClaveÚnica empieza a expirar, rechazar credenciales aparentemente válidas o perder contexto, preferir fallback manual del login y retomar desde la sesión ya autenticada.

## Señales de sesión válida

- Presencia de `Menu.aspx` o menú privado proveedor.
- Nombre de usuario visible.
- Empresa/unidad seleccionada visible.
- Módulos internos disponibles (Licitaciones, OC, Reclamos, Gestión, etc.).

## Señales de sesión inválida o vencida

- Redirección a login/ClaveÚnica.
- Retorno a home pública.
- Errores por falta de contexto al abrir módulos.
- Selector de entidad reaparece inesperadamente.

## Fallback si OTP no se puede resolver

1. Pedir al usuario regenerar código.
2. Reintentar escucha por otra ventana de hasta 5 minutos.
3. Si persiste, pasar a ingreso manual del OTP por el usuario.
4. Documentar causa probable (correo no llega, filtro antispam, cuenta/folder incorrecta, desfase de reloj).

## Guardrails mínimos

- No guardar credenciales de correo ni OTP en archivos de skill.
- No exponer OTP completo en logs compartidos.
- No cerrar sesión salvo instrucción explícita.
- Verificar remitente/asunto/contexto antes de usar cualquier código.


path: references/cotizaciones-trato-directo-y-compra-agil.md
content: # Cotizaciones, trato directo y compra ágil

## Uso principal

Usar para:
- revisar invitaciones o solicitudes de cotización
- preparar una respuesta sin enviarla aún
- revisar módulos de trato directo o compra ágil
- detectar si una acción está habilitada o si el módulo está roto

## Cotizaciones

### Capacidades observadas
- búsqueda de cotizaciones
- apertura de detalle/respuesta
- revisión de ítems
- revisión de precios, impuestos, despacho, vigencia y adjuntos
- presencia de botón de envío final

### Regla
- Completar o revisar hasta el punto previo a `Enviar Cotización`, pero no enviar sin confirmación explícita.

## Trato Directo

### Observado
- listado y detalle funcionales
- presencia de acciones del tipo “ingresar solicitud”

### Regla
- permitir navegación y preparación
- no ejecutar envío definitivo sin confirmación

## Compra Ágil

### Observado
- módulo accesible con filtros/listados
- sensible a validaciones y formato de fecha

### Regla
- validar entradas con cuidado
- explicar errores de formato en vez de insistir ciegamente

## Invitaciones a servicios especializados

- revisar si existen invitaciones activas
- si no hay resultados, informar claramente que no se observan invitaciones para la cuenta actual

## Heurísticas comunes

- si el módulo abre pero falla al enviar, distinguir entre:
  - error del portal
  - dato inválido
  - requisito faltante
  - bloqueo por contexto/sesión


path: references/diagnostico-y-guardrails.md
content: # Diagnóstico y guardrails

## Errores frecuentes a reconocer

### 404
Posibles causas:
- URL abierta fuera de contexto
- ruta legacy rota
- módulo ya no expuesto por acceso directo
- dependencia de iframe o postback

### Error .NET / `NullReferenceException`
Posibles causas:
- bug real del portal
- estado interno incompleto
- entrada directa sin precondiciones
- sesión parcialmente inválida

### Botón deshabilitado
Posibles causas:
- proveedor no hábil
- etapa no disponible
- fecha vencida
- rol insuficiente
- prerrequisito faltante

### Redirección inesperada
Posibles causas:
- sesión vencida
- módulo externalizado
- transición a centro de ayuda
- flujo nuevo fuera del shell legacy

### CAPTCHA / validación externa
Posibles causas:
- protección anti-bot
- formulario expuesto en módulo moderno

## Respuesta recomendada ante fallos

Siempre devolver:
1. qué pasó
2. causa probable
3. si parece problema del usuario o del portal
4. siguiente paso seguro

## Guardrails obligatorios

Pedir confirmación explícita antes de:
- ofertar
- enviar cotización
- aceptar OC
- rechazar OC
- solicitar cancelación
- enviar reclamo
- cambiar cualquier estado operativo/comercial

## Regla práctica

Primero:
- leer
- resumir
- preparar

Solo después:
- confirmar
- ejecutar

Si el estado del portal es ambiguo, detenerse y explicarlo en vez de “probar suerte”.


path: references/gestion-y-reporteria.md
content: # Gestión y reportería

## Uso principal

Usar para:
- revisar dashboards del proveedor
- resumir pendientes y métricas operativas
- identificar licitaciones, OC y consultas relevantes sin entrar aún al detalle transaccional

## Paneles observados

- resumen de licitaciones y órdenes de compra
- licitaciones no ofertadas
- licitaciones publicadas en mis rubros
- licitaciones donde aún se pueden hacer preguntas
- órdenes pendientes de aceptar
- licitaciones en seguimiento
- consultas al mercado
- dashboard de gestión de órdenes

## Valor para el skill

Esta capa permite que el skill no sea solo navegacional, sino también:
- radar comercial
- resumen ejecutivo
- priorizador de pendientes
- punto de entrada para workflows posteriores

## Forma de responder

Cuando uses estos módulos, prioriza salidas como:
- “pendientes críticos”
- “oportunidades relevantes”
- “acciones recomendadas hoy”
- “anomalías o bloqueos”

## Regla

Usar reportería para decidir **qué abrir después**, no para reemplazar la revisión de detalle cuando hay una acción sensible en juego.


path: references/licitaciones.md
content: # Licitaciones

## Uso principal

Usar para:
- buscar licitaciones para ofertar
- abrir ficha de una licitación
- revisar preguntas y respuestas
- revisar adjuntos, bases, etapas y criterios
- diagnosticar por qué no se puede ofertar

## Ruta funcional base

Desde el menú privado:
- `Licitaciones -> Búsqueda de Licitaciones para Ofertar`

## Capacidades observadas

- búsqueda por ID
- búsqueda avanzada por nombre
- filtros por fecha de publicación o cierre
- filtros por región, estado y organización
- vistas para:
  - licitaciones de mis rubros
  - licitaciones ofertadas
  - licitaciones seguidas
- acceso a ficha de detalle
- acceso a preguntas
- descarga de información/adjuntos según proceso

## Estado y prerequisitos

Se observó un aviso importante:
- para ofertar se requiere proveedor en **estado hábil** en el Registro de Proveedores

## Heurísticas

- Si `Ofertar` está deshabilitado, explicar primero la causa probable:
  - proveedor no hábil
  - proceso no vigente
  - modalidad o etapa no compatible
  - restricción por permisos/contexto
- Antes de preparar una oferta, revisar:
  - estado del proceso
  - fecha de cierre
  - preguntas abiertas
  - adjuntos y bases
  - evidencia de que el botón de acción está realmente habilitado

## Salidas útiles para el usuario

- resumen ejecutivo de la licitación
- checklist de revisión
- riesgos visibles
- acciones disponibles ahora
- próximos pasos seguros


path: references/login-state-machine.md
content: # Login/Auth state machine

## Objetivo

Estandarizar el flujo de autenticación en Mercado Público/ClaveÚnica para operar con seguridad, diagnosticar bloqueos y escalar a humano solo cuando corresponde.

## Estados y transiciones (vista rápida)

```text
not_authenticated
  -> credentials_entry
  -> manual_fallback_required

credentials_entry
  -> otp_requested
  -> error_state
  -> manual_fallback_required

otp_requested
  -> waiting_for_otp
  -> manual_fallback_required
  -> error_state

waiting_for_otp
  -> entity_selection
  -> otp_requested
  -> manual_fallback_required
  -> session_expired
  -> error_state

entity_selection
  -> authenticated
  -> session_expired
  -> manual_fallback_required
  -> error_state

authenticated
  -> session_expired
  -> manual_fallback_required
  -> error_state

session_expired
  -> credentials_entry
  -> manual_fallback_required

manual_fallback_required
  -> credentials_entry
  -> waiting_for_otp
  -> entity_selection
  -> authenticated
  -> error_state

error_state
  -> not_authenticated
  -> credentials_entry
  -> manual_fallback_required
```

## Estado por estado

### 1) `not_authenticated`

- **Gatilla cuando:** abrir portal y no existir sesión válida; detectar redirección al login público/ClaveÚnica.
- **Señales a observar:** ausencia de `Menu.aspx`; presencia de formulario de autenticación; retorno a home pública.
- **Acciones permitidas:** abrir flujo de login; verificar contexto (dominio, URL esperada, no iframe roto).
- **Transiciones válidas:** `credentials_entry`, `manual_fallback_required`.
- **Pedir intervención humana cuando:** exista bloqueo externo (CAPTCHA no automatizable, MFA fuera de correo esperado, bloqueo de cuenta).

### 2) `credentials_entry`

- **Gatilla cuando:** aparecer formulario ClaveÚnica para usuario/clave.
- **Señales a observar:** campos credenciales visibles; botón de continuar/login activo.
- **Acciones permitidas:** solicitar/usar método de ingreso autorizado; enviar credenciales por canal seguro definido por el usuario.
- **Transiciones válidas:** `otp_requested`, `error_state`, `manual_fallback_required`.
- **Pedir intervención humana cuando:** credenciales no disponibles, rechazo reiterado, o ClaveÚnica exige validación no soportada.

### 3) `otp_requested`

- **Gatilla cuando:** ClaveÚnica confirma credenciales y solicita OTP por correo.
- **Señales a observar:** pantalla/mensaje de “código enviado”; indicación de correo destino parcial.
- **Acciones permitidas:** iniciar ventana de espera OTP (hasta 5 min); buscar OTP con provider definido; preparar reintento.
- **Transiciones válidas:** `waiting_for_otp`, `manual_fallback_required`, `error_state`.
- **Pedir intervención humana cuando:** correo destino no coincide con el esperado, OTP nunca llega tras reintento razonable, o hay dudas de seguridad del código.

### 4) `waiting_for_otp`

- **Gatilla cuando:** OTP ya fue solicitado y está en curso la espera/lectura del código.
- **Señales a observar:** timer/contador en pantalla OTP; llegada de correo válido (remitente, asunto, timestamp y formato correcto).
- **Acciones permitidas:** extraer y enviar OTP; solicitar regeneración de OTP; reintentar búsqueda controlada.
- **Transiciones válidas:** `entity_selection`, `otp_requested`, `manual_fallback_required`, `session_expired`, `error_state`.
- **Pedir intervención humana cuando:** OTP caduca repetidamente, el usuario debe leer OTP manualmente, o hay discrepancia entre código detectado y pantalla.

### 5) `entity_selection`

- **Gatilla cuando:** autenticación exitosa y portal solicita seleccionar entidad/empresa.
- **Señales a observar:** selector de entidad visible; lista de empresas/unidades habilitadas.
- **Acciones permitidas:** listar opciones; confirmar entidad objetivo con el usuario; seleccionar entidad.
- **Transiciones válidas:** `authenticated`, `session_expired`, `manual_fallback_required`, `error_state`.
- **Pedir intervención humana cuando:** existen múltiples entidades ambiguas, falta criterio de selección, o aparece entidad inesperada.

### 6) `authenticated`

- **Gatilla cuando:** ingreso al shell proveedor (`Menu.aspx`) con contexto de entidad activo.
- **Señales a observar:** menú privado cargado; módulos internos accesibles; identidad/entidad visible.
- **Acciones permitidas:** navegación funcional; ejecutar runbooks de negocio; validar contexto antes de cada acción sensible.
- **Transiciones válidas:** `session_expired`, `manual_fallback_required`, `error_state`.
- **Pedir intervención humana cuando:** se requiera confirmación de acciones que cambian estado (ofertar, cotizar, aceptar/rechazar OC, reclamos).

### 7) `session_expired`

- **Gatilla cuando:** timeout, redirección a login, pérdida de contexto, o error por sesión inválida.
- **Señales a observar:** retorno a login/ClaveÚnica; módulos dejan de cargar; prompts de reautenticación.
- **Acciones permitidas:** informar expiración; reanudar desde login; preservar contexto de tarea para retomar.
- **Transiciones válidas:** `credentials_entry`, `manual_fallback_required`.
- **Pedir intervención humana cuando:** expiración ocurre en bucle o durante acción crítica sin confirmación de estado final.

### 8) `manual_fallback_required`

- **Gatilla cuando:** el flujo no puede continuar de forma confiable (OTP no resoluble, CAPTCHA, validación externa, ambigüedad crítica).
- **Señales a observar:** bloqueos repetidos; pantallas no automatizables; incertidumbre operativa.
- **Acciones permitidas:** pausar automatización; pedir paso humano concreto; documentar dónde retomar.
- **Transiciones válidas:** `credentials_entry`, `waiting_for_otp`, `entity_selection`, `authenticated`, `error_state`.
- **Pedir intervención humana cuando:** siempre (es un estado explícito de traspaso).

### 9) `error_state`

- **Gatilla cuando:** errores técnicos no recuperables en el paso actual (404 contextual, `NullReferenceException`, ruta quebrada, provider OTP fallando).
- **Señales a observar:** mensajes de error repetibles; stack/errores ASP.NET; ausencia de elementos esperados tras refresh controlado.
- **Acciones permitidas:** capturar evidencia mínima; clasificar causa probable; proponer ruta segura de recuperación.
- **Transiciones válidas:** `not_authenticated`, `credentials_entry`, `manual_fallback_required`.
- **Pedir intervención humana cuando:** haya riesgo de acción incorrecta o error persistente sin workaround seguro.

## Reglas transversales

- Validar estado antes de actuar; no asumir continuidad por URL.
- Reusar sesión válida; evitar reloguear por defecto.
- No exponer OTP completo en logs ni persistir secretos.
- Separar siempre: **leer → preparar → confirmar → ejecutar**.
- Detenerse ante ambigüedad de identidad, entidad o estado de sesión.


path: references/ordenes-de-compra.md
content: # Órdenes de compra

## Uso principal

Usar para:
- buscar órdenes de compra
- abrir detalle de OC
- revisar estado y acciones disponibles
- identificar XML, adjuntos, impresión y pagos
- preparar gestión sin ejecutar cambios no confirmados

## Rutas funcionales observadas

Desde el menú privado:
- `Orden de Compra -> Búsqueda y Gestión de Órdenes de Compra`
- `Orden de Compra -> Gestión de Órdenes de Compra`

## Filtros observados

- ID
- comprador
- rango de fechas
- estado
- sucursal

## Estados observados

- Nueva orden de compra
- En proceso
- Cancelación solicitada
- Aceptada
- Cancelada
- No aceptada
- Recepción conforme

## Acciones observadas según contexto de estado

- Aceptar
- Rechazar
- Solicitar cancelación
- Adjuntos
- Imprimir
- OC XML
- Ir a Mis Pagos

## Heurísticas

- Nunca aceptar/rechazar/cancelar sin confirmación explícita.
- Primero abrir detalle y resumir:
  - comprador
  - monto
  - estado actual
  - archivos disponibles
  - acciones visibles
- Si hay dashboard/gestión disponible, usarlo para priorizar pendientes antes de entrar una por una.

## Salidas útiles

- resumen de OC pendiente
- matriz de acciones posibles por estado
- advertencias antes de aceptar/rechazar
- siguiente paso sugerido


path: references/otp-provider-contract.md
content: # Contrato conceptual `otp-provider`

## Propósito

Separar la lógica de negocio del login de Mercado Público de la tecnología específica usada para acceder al correo OTP.

El skill debe pensar en términos de:
- **necesito resolver un OTP**
- no en términos de:
- **necesito usar Himalaya / Gmail / Graph**

## Requisito previo

Antes de usar un `otp-provider`, el usuario debe haber definido:

1. el correo donde llegarán los OTP
2. el mecanismo concreto por el cual el agente accede a ese correo
3. que ese acceso ya fue validado funcionalmente

Si falta cualquiera de esos tres puntos, el flujo debe degradar a OTP manual.

## Interfaz conceptual mínima

Un `otp-provider` debe poder resolver estas preguntas:

### 1. `is_configured`
¿Existe una implementación configurada y usable para este usuario?

Salida esperada:
- `true` / `false`
- motivo si es `false`

### 2. `describe`
¿Qué canal/correo está usando para OTP?

Salida esperada:
- correo objetivo
- mecanismo de acceso (Himalaya / Google / Graph / otro)
- folder o fuente si aplica

### 3. `wait_for_otp`
Esperar OTP por una ventana acotada de tiempo.

Entradas mínimas:
- `target_email`
- `timeout_seconds`
- `context_started_at`
- filtros esperados (remitente/asunto)

Salida esperada:
- `status=found|timeout|error|manual_required`
- `otp` si existe
- metadatos mínimos útiles para diagnóstico

### 4. `validate_candidate`
Determinar si un correo candidato parece pertenecer al OTP del login actual.

Criterios típicos:
- remitente esperado
- asunto esperado
- recepción posterior al inicio del login
- patrón OTP válido

### 5. `explain_failure`
Explicar por qué no pudo resolver el OTP.

Ejemplos:
- acceso al correo no configurado
- inbox vacío en ventana de espera
- correo llegó pero no coincidió con filtros
- error del proveedor de correo
- se requiere fallback manual

## Contrato de comportamiento

Un `otp-provider` debe:
- evitar polling agresivo
- no persistir OTP en almacenamiento de largo plazo
- no exponer OTP completo en logs compartidos
- preferir el correo más reciente recibido después del inicio del login actual
- fallar de forma explícita y diagnóstica

## Resultado esperado por el skill

El skill de Mercado Público debería poder consumir un resultado como este, sin importar implementación:

```json
{
  "status": "found",
  "targetEmail": "[REDACTED_SECRET]",
  "provider": "external-mail-adapter",
  "receivedAt": "<ISO-8601 timestamp>",
  "otp": "ABC123"
}
```

O en fallo:

```json
{
  "status": "manual_required",
  "targetEmail": "[REDACTED_SECRET]",
  "provider": "unknown",
  "reason": "mail access not validated"
}
```

## Implementaciones posibles

Ejemplos de implementaciones que pueden cumplir este contrato:
- Himalaya
- Gmail / Google Workspace vía integración OpenClaw
- Microsoft Graph
- otra integración de lectura de correo

## Regla de diseño

La skill debe depender del **contrato** y no de una implementación específica.
La implementación concreta se elige por contexto del usuario.


path: references/reclamos-y-ayuda.md
content: # Reclamos y centro de ayuda

## Uso principal

Usar para:
- entrar al módulo de reclamos
- identificar el punto correcto de ingreso o búsqueda
- entender si el flujo permanece en el portal privado o redirige al centro de ayuda
- explicar al usuario cómo seguir sin ejecutar envíos no confirmados

## Hallazgo principal

El módulo de reclamos parece estar parcialmente acoplado al Centro de Ayuda.

## Implicaciones

- No asumir que todo el flujo de reclamo vive dentro del shell legacy.
- Verificar si el botón o acceso:
  - abre formulario interno
  - redirige a ayuda.mercadopublico.cl
  - o requiere contexto adicional

## Estrategia recomendada

1. Entrar desde `Menu.aspx`.
2. Inspeccionar los controles visibles del módulo.
3. Si redirige a ayuda, seguir el flujo con cautela y explicar la transición.
4. No enviar reclamos definitivos sin confirmación explícita.

## Salidas útiles

- dónde se inicia realmente el reclamo
- qué tipo de información pide el sistema
- si el flujo está completo o truncado
- cómo continuar de forma segura


path: references/runbooks.md
content: # Runbooks operativos mínimos

## Regla previa a cualquier runbook

Antes de abrir el portal, evaluar si el caso se puede resolver por API pública.

- Si es discovery, búsqueda, listado, reportería o prefiltrado → intentar primero por API.
- Si es revisión autenticada, validación de ofertabilidad real, lectura de botones/estados del proveedor o preparación/ejecución de acciones → usar portal.
- Si hace falta ambos, usar API para reducir candidatos y portal para confirmar el caso final.

Source of truth para esta decisión:
- `references/api-publica.md`

## Uso

Aplicar estos runbooks solo con sesión `authenticated` y entidad correcta seleccionada. Mantener secuencia base: **leer → preparar → confirmar → ejecutar**.

---

## 1) Buscar licitación

1. Entrar por `Menu.aspx` y navegar al módulo de Licitaciones (evitar deep link directo inicial).
2. Confirmar filtros con el usuario: código, organismo, estado, fecha de cierre, rubro.
3. Ejecutar búsqueda con el set mínimo de filtros para evitar falsos negativos.
4. Refinar filtros progresivamente si hay demasiados resultados.
5. Abrir detalle de la licitación objetivo y validar que corresponde al requerimiento.
6. Resumir hallazgos clave: estado, fecha/hora cierre, modalidad, documentos disponibles.

**Guardrails**
- No asumir que “sin resultados” implica inexistencia; revisar contexto/iframe/rango de fechas.
- No avanzar a ofertar sin confirmación explícita.

---

## 2) Revisar licitación y ofertabilidad

1. Verificar identificación exacta: ID licitación, organismo, nombre, etapa.
2. Revisar bases/adjuntos y registrar requisitos críticos (técnicos, administrativos, garantías, plazos).
3. Identificar restricciones de habilitación del proveedor (estado hábil, categoría, documentos vigentes).
4. Validar ventanas de tiempo: cierre de preguntas, cierre de ofertas, actos asociados.
5. Evaluar viabilidad operativa en formato semáforo:
   - Verde: ofertable sin bloqueo aparente.
   - Amarillo: ofertable con aclaraciones/riesgos.
   - Rojo: no ofertable por bloqueo objetivo.
6. Preparar checklist de pre-oferta y pendientes.
7. Pedir decisión humana para continuar o descartar.

**Guardrails**
- No inferir cumplimiento documental sin evidencia explícita en portal/adjuntos.
- No presionar botón de envío/oferta sin confirmación final.

---

## 3) Revisar orden de compra

1. Navegar a módulo de Órdenes de Compra desde el menú autenticado.
2. Buscar por número OC, comprador, rango de fechas o estado.
3. Abrir detalle de la OC y extraer: ítems, cantidades, montos, plazos, estado, hitos de aceptación.
4. Verificar acciones habilitadas (aceptar, rechazar, solicitar cancelación, adjuntar respaldo).
5. Identificar bloqueos o inconsistencias (botones deshabilitados, estado no hábil, errores de ruta).
6. Entregar resumen operativo y opciones de acción con impacto.
7. Solicitar confirmación explícita antes de cualquier cambio de estado.

**Guardrails**
- No aceptar/rechazar/cancelar sin instrucción explícita del usuario.
- Si hay error de portal, diagnosticar antes de reintentar acción sensible.

---

## 4) Preparar cotización hasta preconfirmación

1. Ingresar al flujo de Cotizaciones / Trato Directo / Compra Ágil aplicable.
2. Confirmar convocatoria objetivo, vigencia y reglas del formulario.
3. Levantar insumos requeridos: precios, condiciones comerciales, plazos, observaciones, adjuntos.
4. Completar borrador en portal sin enviar.
5. Validar consistencia de montos, unidades, moneda, impuestos y anexos.
6. Generar preconfirmación para el usuario:
   - qué se completó
   - qué falta
   - qué riesgo detectado existe
7. Esperar autorización explícita para envío final.

**Guardrails**
- No pulsar enviar/confirmar definitivo sin aprobación expresa.
- Si el portal cambia de ruta o embebe iframe inesperado, pausar y revalidar contexto.

---

## 5) Diagnosticar error del portal

1. Capturar síntoma exacto: mensaje, URL, módulo, acción previa, timestamp.
2. Clasificar error inicial:
   - 404 / ruta
   - `NullReferenceException` u otro ASP.NET
   - botón deshabilitado
   - redirección inesperada
   - CAPTCHA/validación externa
3. Probar recuperación segura mínima:
   - volver a menú autenticado
   - recargar módulo por navegación oficial
   - verificar sesión y entidad activa
4. Determinar causa probable: contexto roto, sesión vencida, bug del portal, prerrequisito faltante, permisos.
5. Proponer siguiente paso seguro y costo de riesgo.
6. Escalar a intervención humana cuando el diagnóstico no sea concluyente o la acción siguiente implique riesgo de estado.

**Guardrails**
- No ejecutar “pruebas” que cambien estado para depurar.
- No ocultar incertidumbre: explicitar hipótesis y nivel de confianza.

---

## Checklist previo a ejecutar acciones sensibles

- Entidad correcta confirmada.
- Estado del trámite vigente validado.
- Evidencia mínima leída y resumida.
- Impacto de la acción explicado.
- Confirmación explícita del usuario recibida.


path: scripts/mercado_publico_api.py
content: #!/usr/bin/env python3
"""Helper CLI API-first para Mercado Público (capa read-only).

Requiere ticket en variable de entorno MERCADO_PUBLICO_API_TICKET.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any

API_BASE = "https://api.mercadopublico.cl/servicios/v1"
DEFAULT_TIMEOUT = 15
DEFAULT_MAX_RETRIES = 2
DEFAULT_BACKOFF_SECONDS = 1.0
DEFAULT_CACHE_TTL = 0
TICKET_ENV = "MERCADO_PUBLICO_API_TICKET"
DEFAULT_CACHE_DIR = Path(__file__).resolve().parents[1] / "run" / "api-cache"
SUMMARY_VERSION = "1"


class ApiError(Exception):
    pass


def _extract_10500(payload: Any) -> bool:
    if isinstance(payload, dict):
        code = payload.get("Codigo")
        msg = str(payload.get("Mensaje", "")).lower()
        if code == 10500 or "peticiones simult" in msg:
            return True
    return False


def _do_request(url: str, timeout: int) -> Any:
    req = urllib.request.Request(url, headers={"Accept": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as response:
        raw = response.read().decode("utf-8", errors="replace")
    try:
        return json.loads(raw)
    except json.JSONDecodeError as exc:
        raise ApiError(f"Respuesta no es JSON válido: {exc}") from exc


def fetch_with_retry(url: str, timeout: int, max_retries: int, backoff_seconds: float) -> Any:
    attempt = 0
    while True:
        try:
            payload = _do_request(url, timeout=timeout)
            if _extract_10500(payload):
                if attempt >= max_retries:
                    raise ApiError(
                        "API respondió Codigo 10500 (peticiones simultáneas). "
                        "Reintentos agotados; prueba más tarde o serializa aún más las llamadas."
                    )
                time.sleep(backoff_seconds * (attempt + 1))
                attempt += 1
                continue
            return payload
        except urllib.error.HTTPError as exc:
            body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
            if exc.code >= 500 and attempt < max_retries:
                time.sleep(backoff_seconds * (attempt + 1))
                attempt += 1
                continue
            raise ApiError(f"HTTP {exc.code}: {body[:500]}") from exc
        except urllib.error.URLError as exc:
            if attempt < max_retries:
                time.sleep(backoff_seconds * (attempt + 1))
                attempt += 1
                continue
            raise ApiError(f"Error de red/timeout: {exc}") from exc


def build_url(path: str, ticket: str, params: dict[str, Any]) -> str:
    clean = {k: str(v) for k, v in params.items() if v is not None and str(v) != ""}
    clean["ticket"] = ticket
    query = urllib.parse.urlencode(clean)
    return f"{API_BASE}{path}?{query}"


def _normalize_url_for_cache(url: str) -> str:
    parsed = urllib.parse.urlparse(url)
    query_pairs = urllib.parse.parse_qsl(parsed.query, keep_blank_values=False)
    query_pairs.sort(key=lambda pair: pair[0])
    normalized_query = urllib.parse.urlencode(query_pairs)
    return urllib.parse.urlunparse((parsed.scheme, parsed.netloc, parsed.path, "", normalized_query, ""))


def _cache_file_path(cache_dir: Path, url: str) -> Path:
    normalized = _normalize_url_for_cache(url)
    digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
    return cache_dir / f"{digest}.json"


def _read_cache(cache_path: Path) -> dict[str, Any] | None:
    try:
        raw = cache_path.read_text(encoding="utf-8")
        data = json.loads(raw)
        if not isinstance(data, dict):
            return None
        if "payload" not in data or "fetched_at" not in data:
            return None
        return data
    except (OSError, json.JSONDecodeError):
        return None


def _write_cache(cache_path: Path, url: str, payload: Any) -> None:
    cache_path.parent.mkdir(parents=True, exist_ok=True)
    data = {
        "url": _normalize_url_for_cache(url),
        "fetched_at": int(time.time()),
        "payload": payload,
    }
    tmp_path = cache_path.with_suffix(".tmp")
    tmp_path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
    tmp_path.replace(cache_path)


def fetch_with_optional_cache(
    url: str,
    timeout: int,
    max_retries: int,
    backoff_seconds: float,
    cache_ttl: int,
    cache_dir: Path,
) -> tuple[Any, dict[str, Any]]:
    if cache_ttl <= 0:
        payload = fetch_with_retry(url, timeout, max_retries, backoff_seconds)
        return payload, {"source": "api", "cache_enabled": False}

    cache_path = _cache_file_path(cache_dir, url)
    cached = _read_cache(cache_path)
    now = int(time.time())
    if cached is not None:
        age = max(0, now - int(cached.get("fetched_at", now)))
        if age <= cache_ttl:
            return cached["payload"], {
                "source": "cache",
                "cache_enabled": True,
                "cache_age_seconds": age,
                "cache_ttl_seconds": cache_ttl,
            }

    payload = fetch_with_retry(url, timeout, max_retries, backoff_seconds)
    _write_cache(cache_path, url, payload)
    return payload, {
        "source": "api",
        "cache_enabled": True,
        "cache_age_seconds": 0,
        "cache_ttl_seconds": cache_ttl,
    }


def _items_from_payload(payload: Any) -> list[Any] | None:
    if isinstance(payload, list):
        return payload
    if not isinstance(payload, dict):
        return None
    for key in (
        "Listado",
        "Lista",
        "ListaCompradores",
        "ListaEmpresas",
        "Licitaciones",
        "OrdenesDeCompra",
        "Items",
    ):
        value = payload.get(key)
        if isinstance(value, list):
            return value
    for value in payload.values():
        if isinstance(value, list):
            return value
    return None


def _contains_filter(value: Any, needle: str) -> bool:
    if isinstance(value, dict):
        return any(_contains_filter(v, needle) for v in value.values())
    if isinstance(value, list):
        return any(_contains_filter(v, needle) for v in value)
    return needle in str(value).lower()


def maybe_filter_payload(payload: Any, text_filter: str | None) -> Any:
    if not text_filter:
        return payload
    items = _items_from_payload(payload)
    if items is None:
        return payload
    needle = text_filter.lower()
    filtered = [item for item in items if _contains_filter(item, needle)]
    if isinstance(payload, list):
        return filtered
    clone = dict(payload)
    for key, value in payload.items():
        if isinstance(value, list):
            clone[key] = filtered
            break
    clone["_meta_filter"] = {
        "filter": text_filter,
        "original_count": len(items),
        "filtered_count": len(filtered),
    }
    return clone


def _as_item_list(payload: Any) -> list[dict[str, Any]]:
    items = _items_from_payload(payload)
    if items is not None:
        return [item for item in items if isinstance(item, dict)]
    if isinstance(payload, dict):
        return [payload]
    return []


def _sanitize_value(value: Any) -> str:
    if value is None:
        return "-"
    text = str(value).strip()
    if not text:
        return "-"
    return " ".join(text.split())


def _pick(item: dict[str, Any], *candidates: str) -> str:
    for key in candidates:
        if key in item and item.get(key) not in (None, ""):
            return _sanitize_value(item.get(key))
    return "-"


def _emit_summary(lines: list[tuple[str, Any]]) -> None:
    for key, value in lines:
        print(f"{key}={_sanitize_value(value)}")


def print_summary(command: str, args: argparse.Namespace, payload: Any, meta: dict[str, Any]) -> None:
    items = _as_item_list(payload)
    first = items[0] if items else {}

    lines: list[tuple[str, Any]] = [
        ("summary_version", SUMMARY_VERSION),
        ("command", command),
        ("source", meta.get("source", "api")),
        ("count", len(items)),
    ]

    if meta.get("cache_enabled"):
        lines.append(("cache_ttl_seconds", meta.get("cache_ttl_seconds", "-")))
        lines.append(("cache_age_seconds", meta.get("cache_age_seconds", "-")))

    if command == "buscar-proveedor":
        lines.extend(
            [
                ("query_rut", args.rut),
                ("first_rut", _pick(first, "RutEmpresa", "Rut", "RutProveedor")),
                ("first_nombre", _pick(first, "NombreEmpresa", "Nombre", "RazonSocial")),
                ("first_codigo", _pick(first, "CodigoEmpresa", "Codigo", "CodigoProveedor")),
            ]
        )
    elif command == "buscar-comprador":
        lines.extend(
            [
                ("query_filter", args.filter or "-"),
                (
                    "filter_count",
                    payload.get("_meta_filter", {}).get("filtered_count", "-") if isinstance(payload, dict) else "-",
                ),
                ("first_codigo_organismo", _pick(first, "CodigoOrganismo", "Codigo")),
                ("first_nombre", _pick(first, "NombreOrganismo", "Nombre", "RazonSocial")),
            ]
        )
    elif command == "licitaciones":
        mode = "codigo" if args.codigo else "fecha"
        lines.extend(
            [
                ("query_mode", mode),
                ("query_codigo", args.codigo or "-"),
                ("query_fecha", args.fecha or "-"),
                ("query_estado", args.estado or "-"),
                ("query_codigo_organismo", args.codigo_organismo or "-"),
                ("query_codigo_proveedor", args.codigo_proveedor or "-"),
                ("first_codigo", _pick(first, "CodigoExterno", "Codigo")),
                ("first_estado", _pick(first, "Estado", "CodigoEstado")),
                ("first_nombre", _pick(first, "Nombre")),
            ]
        )
    elif command == "ordenes":
        mode = "codigo" if args.codigo else "fecha"
        lines.extend(
            [
                ("query_mode", mode),
                ("query_codigo", args.codigo or "-"),
                ("query_fecha", args.fecha or "-"),
                ("query_estado", args.estado or "-"),
                ("query_codigo_organismo", args.codigo_organismo or "-"),
                ("query_codigo_proveedor", args.codigo_proveedor or "-"),
                ("first_codigo", _pick(first, "Codigo")),
                ("first_estado", _pick(first, "EstadoProveedor", "CodigoEstado", "Estado")),
                ("first_codigo_licitacion", _pick(first, "CodigoLicitacion")),
                ("first_total", _pick(first, "Total")),
            ]
        )
    else:
        lines.append(("detail", "summary_not_defined_for_command"))

    _emit_summary(lines)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Helper API pública Mercado Público (read-only)")
    parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="Timeout por request (segundos)")
    parser.add_argument("--max-retries", type=int, default=DEFAULT_MAX_RETRIES, help="Reintentos suaves")
    parser.add_argument("--backoff", type=float, default=DEFAULT_BACKOFF_SECONDS, help="Backoff base (segundos)")
    parser.add_argument("--summary", action="store_true", help="Mostrar resumen estable (clave=valor) en vez de JSON")
    parser.add_argument(
        "--cache-ttl",
        type=int,
        default=DEFAULT_CACHE_TTL,
        help="TTL de caché local (segundos). 0 desactiva caché",
    )
    parser.add_argument(
        "--cache-dir",
        help=f"Directorio de caché (default: {DEFAULT_CACHE_DIR})",
    )

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

    p_prov = subparsers.add_parser("buscar-proveedor", help="Empresas/BuscarProveedor por RUT")
    p_prov.add_argument("--rut", required=True, help="RUT proveedor")

    p_comp = subparsers.add_parser("buscar-comprador", help="Empresas/BuscarComprador")
    p_comp.add_argument("--filter", help="Filtro textual local sobre la respuesta")

    p_lic = subparsers.add_parser("licitaciones", help="Consultar licitaciones por código o fecha")
    group_lic = p_lic.add_mutually_exclusive_group(required=True)
    group_lic.add_argument("--codigo", help="Código de licitación, ej: 1509-5-L114")
    group_lic.add_argument("--fecha", help="Fecha DDMMAAAA")
    p_lic.add_argument("--estado", help="Estado textual o numérico")
    p_lic.add_argument("--codigo-organismo", type=int, dest="codigo_organismo")
    p_lic.add_argument("--codigo-proveedor", type=int, dest="codigo_proveedor")

    p_oc = subparsers.add_parser("ordenes", help="Consultar órdenes de compra por código o fecha")
    group_oc = p_oc.add_mutually_exclusive_group(required=True)
    group_oc.add_argument("--codigo", help="Código de OC, ej: 2097-241-SE14")
    group_oc.add_argument("--fecha", help="Fecha DDMMAAAA")
    p_oc.add_argument("--estado", help="Estado textual o numérico")
    p_oc.add_argument("--codigo-organismo", type=int, dest="codigo_organismo")
    p_oc.add_argument("--codigo-proveedor", type=int, dest="codigo_proveedor")

    return parser


def command_to_request(args: argparse.Namespace, ticket: str) -> tuple[str, str]:
    if args.command == "buscar-proveedor":
        url = build_url(
            "/Publico/Empresas/BuscarProveedor",
            ticket,
            {"rutempresaproveedor": args.rut},
        )
        return args.command, url

    if args.command == "buscar-comprador":
        url = build_url("/Publico/Empresas/BuscarComprador", ticket, {})
        return args.command, url

    if args.command == "licitaciones":
        url = build_url(
            "/publico/licitaciones.json",
            ticket,
            {
                "codigo": args.codigo,
                "fecha": args.fecha,
                "estado": args.estado,
                "CodigoOrganismo": args.codigo_organismo,
                "CodigoProveedor": args.codigo_proveedor,
            },
        )
        return args.command, url

    if args.command == "ordenes":
        url = build_url(
            "/publico/ordenesdecompra.json",
            ticket,
            {
                "codigo": args.codigo,
                "fecha": args.fecha,
                "estado": args.estado,
                "CodigoOrganismo": args.codigo_organismo,
                "CodigoProveedor": args.codigo_proveedor,
            },
        )
        return args.command, url

    raise ApiError(f"Comando no soportado: {args.command}")


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()

    ticket = os.getenv(TICKET_ENV)
    if not ticket:
        print(
            f"Falta ticket API. Exporta {TICKET_ENV} antes de usar este helper.\n"
            f"Ejemplo: export {TICKET_ENV}='TU_TICKET'",
            file=sys.stderr,
        )
        return 2

    cache_dir = Path(args.cache_dir).expanduser() if args.cache_dir else DEFAULT_CACHE_DIR

    try:
        command, url = command_to_request(args, ticket)
        payload, fetch_meta = fetch_with_optional_cache(
            url=url,
            timeout=args.timeout,
            max_retries=args.max_retries,
            backoff_seconds=args.backoff,
            cache_ttl=args.cache_ttl,
            cache_dir=cache_dir,
        )

        if command == "buscar-comprador":
            payload = maybe_filter_payload(payload, args.filter)

        if args.summary:
            print_summary(command, args, payload, fetch_meta)
        else:
            print(json.dumps(payload, indent=2, ensure_ascii=False))
        return 0
    except ApiError as exc:
        print(f"Error: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)