--- id: 0080 title: tables playground — LLM "Ask AI" + TQL/SQL emit (fase 11) status: done priority: medium created: 2026-05-12 updated: 2026-05-13 closed: 2026-05-13 notes: | pure layer + LLM client + Ask AI modal + DuckDB adapter (FN_TQL_DUCKDB=ON opt-in). 618 tests pass con DuckDB (round-trip TQL emit -> execute -> match). 603 sin. e2e linux+windows OK ambos modos. related_components: [cpp/apps/primitives_gallery/playground/tables, lua_engine, tql, duckdb] --- ## Contexto Fase 11 del roadmap del tables playground. Dos capacidades que se construyen juntas porque comparten infra (prompt schema, runtime adapter, tests round-trip): 1. **LLM "Ask AI"** — usuario o agente pregunta en lenguaje natural, modelo devuelve un nuevo TQL (o SQL DuckDB si esta linkado). 2. **TQL → SQL (DuckDB) emitter** — permite a agentes escribir SQL contra el mismo modelo de datos. Ejecutable si la app linkó DuckDB; si no, solo emite el string. Diseño one-way: **TQL → SQL si**, **SQL → TQL no**. Razon documentada en investigacion Metabase MBQL ↔ SQL: la traduccion inversa es lossy (CTEs, window fns, set ops, lateral, correlated subqueries no caben en MBQL/TQL). Patron canonico Malloy/Cube/LookML/Metabase = compile-down one-way. ## Cambios ### 1. UI "Ask AI" - Boton "Ask AI" en toolbar (al lado de "+ Viz"). - Modal: - InputText multiline para la pregunta. - Toggle output mode: `TQL` (default) | `SQL (DuckDB)` (visible solo si app fue compilada con `FN_TQL_DUCKDB=1`). - Boton "Send" + spinner. - Diff side-by-side: actual vs propuesto (texto highlight). - Botones "Apply" / "Reject" / "Edit before apply". ### 2. Backend LLM - Provider: Anthropic Claude. API key via `pass anthropic/api-key`. - Endpoint: `https://api.anthropic.com/v1/messages`. Model: `claude-sonnet-4-6`. Override env `FN_LLM_MODEL`. - Cliente HTTP: cURL via popen (sin deps nuevas). - Prompt template incluye: - Esquema TQL (de `docs/TQL.md`). - **Si SQL mode**: dialecto DuckDB + funciones DuckDB relevantes (date_trunc, regexp_replace, etc.). - Cols disponibles del stage 0 (name, type) + cols joinables. - **Grammar Lua subset** (ver §4) cuando aplique. - Funciones Lua disponibles (de `lua_engine`). - TQL actual. - Pregunta del user. - Response: extraer ```lua``` (TQL) o ```sql``` block del markdown, strip prose. ### 3. TQL → SQL DuckDB emitter Nuevo modulo `tql_to_sql.{h,cpp}` (pure). Funciones: ```cpp struct SqlEmit { std::string sql; // SELECT ... statement std::vector params; // bound values (?-placeholders) std::vector warnings; std::string error; // si emit fallo (subset out of bounds) }; // Pure: emite SQL DuckDB equivalente a la pipeline State (stages 0..active). // `tables` provee el schema de cada TableInput (no los cells — el caller // decide como hidratar las tablas en DuckDB). SqlEmit emit_sql(const State& state, const std::vector& tables, int up_to_stage = -1 /* default = active_stage */); ``` Mapeo MBQL-style: - Stage 0 = CTE base `t0` con `SELECT cols + derived FROM main_t [LEFT/INNER/RIGHT/FULL JOIN joinables ON ...]`. - Stage N = CTE `tN` con `SELECT breakouts, aggregations FROM tN-1 [WHERE filters] [GROUP BY breakouts] [ORDER BY sorts]`. - Final query `SELECT * FROM t`. Stage emit detalle: - `filter Op::Eq col = "v"` → `WHERE col = ?` con `params.push_back(v)` (DuckDB acepta `$1`/`?`). - `breakout "ts:month"` → `date_trunc('month', ts) AS "ts:month"`. Granularity sufijo → DuckDB `date_trunc`. - `aggregation count` → `COUNT(*) AS count`. - `aggregation p95(col)` → `quantile_cont(col, 0.95) AS p95_col`. - `aggregation distinct col` → `COUNT(DISTINCT col) AS distinct_col`. - `sort {desc, col}` → `ORDER BY col DESC`. - Joins: 4 strategies mapean directo a `LEFT/INNER/RIGHT/FULL JOIN ... ON l.k = r.k`. - Derived cols: transpiladas via Lua subset (§4). Si formula fuera de subset → `SqlEmit.error = "lua formula 'X' out of subset: "`. Salida es **string SQL valido DuckDB**. No ejecuta — eso es responsabilidad del adapter opcional (§5). ### 4. Lua subset transpilable a SQL — GRAMATICA Documentar en `docs/TQL.md` seccion nueva "SQL transpile subset". **Reglas duras: Lua sigue siendo potente y sin limites en runtime general.** El subset solo aplica si el caller pide `tql_to_sql::emit_sql()`. Fuera del subset → error claro en tiempo de emit, NO en tiempo de eval. El playground sigue ejecutando Lua arbitrario sin restriccion. **Subset permitido (transpila a SQL):** | Lua | SQL DuckDB | |---|---| | Literales: numero, string `"x"`, bool `true/false`, `nil` | `1.5`, `'x'`, `TRUE/FALSE`, `NULL` | | Col ref: `[colname]` | `colname` (identifier quoted si necesario) | | Aritmetica: `+ - * / % - (unary)` | mismas | | Comparacion: `== ~= < <= > >=` | `= <> < <= > >=` | | Logica: `and or not` | `AND OR NOT` | | String concat: `..` | `\|\|` | | Ternary: `if A then B else C end` | `CASE WHEN A THEN B ELSE C END` | | Ternary inline: `(A and B) or C` (pattern comun Lua) | `CASE WHEN A THEN B ELSE C END` | | `math.floor/ceil/abs/round/sqrt/sin/cos/log` | `floor/ceiling/abs/round/sqrt/sin/cos/ln` | | `math.min(a,b)/max(a,b)` | `least(a,b)/greatest(a,b)` | | `string.upper/lower/len(s)` | `upper(s)/lower(s)/length(s)` | | `string.sub(s, i, j)` | `substring(s, i, j-i+1)` | | `tostring(x)/tonumber(x)` | `CAST(x AS VARCHAR)/CAST(x AS DOUBLE)` | | Paréntesis y precedencia | mismas | **Fuera de subset (error compile-time):** - Closures: `function() ... end` - Loops: `for/while/repeat` - Locals: `local x = ...` - Tables: `{...}`, `t[k]`, `t.field`, `table.*` - Multi-return / vararg - `string.gsub/find/match/format` (mapeo manual posible v2) - IO: `io.*`, `os.*`, `print` - Coroutines, metatables, debug - Recursion, multi-statement bodies **Error message ejemplo:** ``` SQL transpile error en derived col 'fullname': formula = "[first] .. ' ' .. table.concat(parts, ',')" causa: 'table.concat' no esta en SQL transpile subset ver docs/TQL.md#sql-transpile-subset workaround: usar TQL puro (sin SQL emit) o reescribir formula con `..` ``` **Helper:** `tql_to_sql::is_transpilable(formula, error_out)` pure fn que valida una formula sin emitir. ### 5. DuckDB adapter (opcional) Build flag `FN_TQL_DUCKDB=1` en `cpp/CMakeLists.txt` opta-in. Vendor DuckDB header-only o lib (depende de tamaño). Default OFF — playground sigue compilando sin DuckDB. API adapter: ```cpp namespace tql_duckdb { struct Result { StageOutput out; // materializado como TableInput compatible std::string error; double duration_ms = 0; }; // Hidrata `tables` como views temp + ejecuta sql + materializa resultado. Result execute(const std::string& sql, const std::vector& params, const std::vector& tables); } ``` Apps que lo usen (registry_dashboard, sqlite_api): linkean DuckDB + invocan adapter cuando user/agent pide SQL output. Playground por defecto NO linka — `Ask AI` solo ofrece SQL mode si `#ifdef FN_TQL_DUCKDB`. ### 6. Validacion + safety - Antes de aplicar TQL del LLM: `tql::apply` dry-run. Si fail, mostrar error + "Ask AI again with this error". - Antes de ejecutar SQL del LLM: parsing DuckDB en sandbox read-only (DuckDB connection sin `INSERT/UPDATE/DELETE/DROP`, attach read-only). - Lua sandbox ya cubre side effects en formulas TQL. ### 7. Streaming - Stream tokens via SSE (`stream=true` Anthropic). - Texto en vivo en modal. - Cuando termina, parse lua/sql block final. ### 8. Persistencia conversacion - UiState guarda lista de turns (pregunta + output propuesto + apply result + engine usado TQL/SQL). - Siguiente "Ask AI" turn incluye history previa. - Boton "Reset chat". - NO persistido en TQL (UI state efimero). ### 9. Coste / rate limit - Mostrar tokens estimados antes de enviar (rough char count / 4). - Cap input a 8000 tokens. - Error handling: 429 / 5xx → mensaje + reintentar. ## Tests ### Pure (sin red, sin DuckDB linkado) - **Lua subset validator:** `is_transpilable` true para casos subset, false con error claro para fuera de subset (closures, loops, table.*, string.gsub, etc.). - **TQL → SQL emit golden tests** (~20 casos): - stage 0 simple filter + sort → `SELECT ... WHERE ... ORDER BY ...` - stage 1 group + count → CTE chain con GROUP BY - granularity sufijo `:month` → `date_trunc('month', ts)` - join 4 strategies con multi-key - derived cols subset → CASE/expressions - derived cols fuera subset → `SqlEmit.error` no vacio + warning - aggregation p25/p50/p75/p99 → `quantile_cont(col, p)` - empty pipeline → `SELECT * FROM t0` - **TQL parseo:** prompt build incluye schema + TQL + pregunta en formato esperado (mockear HTTP). - **Response parse:** extrae lua/sql block correctamente. ### Round-trip (requiere DuckDB linkado) Solo corren si `FN_TQL_DUCKDB=1`: - TQL → emit SQL → ejecutar DuckDB → resultado coincide bit-a-bit con `compute_stage` pure sobre los mismos cells. - Casos: filter, group+agg, join inner, multi-stage chain, breakout granularity month/week, derived col `[a] + [b] * 2`. ### LLM (red real, opt-in) - Test integration con `ANTHROPIC_API_KEY` real (`make test-llm`): pregunta simple → recibe TQL valido → apply OK. - Mock test (CI): cURL stub responde con JSON predefinido → parser extrae bloque OK. ## No-objetivos - **SQL → TQL**: no se implementa. Documentado en doc + en mensajes de error del Ask AI ("no soportamos SQL como input, use TQL"). - **Multi-provider** (OpenAI, local): fase futura. Anthropic hardcoded v1. - **Generacion de viz desde LLM** mas alla de `display` token: la viz la elige TQL existente. - **Lua subset extension** (string.gsub, regex, table.*): postpone v2 si demanda real. - **DuckDB write ops**: solo SELECT/CTE. Apps que quieran INSERT/UPDATE lo hacen fuera del playground. ## Flujo agente (resumen) ``` Agente -> "muestrame top 10 langs por total size" LLM (TQL default) -> emite TQL { stages = {...} } tql::apply -> State + dry-run OK User clickea Apply -> compute_stage en memoria Agente -> "lo mismo pero como SQL" [Si FN_TQL_DUCKDB=1 y app linkó adapter] LLM (SQL mode toggled) -> emite SELECT ... DuckDB duckdb::execute(sql, params, tables) -> resultado materializado [Si NO linkado] -> error "SQL mode requiere DuckDB. Compila con FN_TQL_DUCKDB=1" ``` ## Riesgos - **Subset Lua restrictivo en SQL emit**: usuarios usan Lua arbitrario en playground → al pedir SQL falla. Mitigacion: error message claro + sugerencia workaround. - **DuckDB tamaño**: lib ~10MB. Solo se paga si app opta-in con build flag. - **Dialect drift DuckDB**: funciones SQL pueden cambiar entre versiones. Pinear DuckDB version en CMake. - **LLM hallucinations**: TQL invalido → dry-run rechaza con error. Loop "Ask AI again with this error" recupera. - **API key leak**: `pass` integration mantiene fuera del repo. Build flag NUNCA imprime key. - **Coste tokens**: prompt grande (schema + grammar + TQL). Cap input + warning visual.