Files
fn_registry/dev/issues/0080-tables-llm-api.md
T
egutierrez e3c8979e8d chore: auto-commit (95 archivos)
- cmd/fn/doctor.go
- cmd/fn/main.go
- cpp/apps/primitives_gallery/playground/tables/CMakeLists.txt
- cpp/apps/primitives_gallery/playground/tables/data_table.cpp
- cpp/apps/primitives_gallery/playground/tables/data_table_logic.cpp
- cpp/apps/primitives_gallery/playground/tables/data_table_logic.h
- cpp/apps/primitives_gallery/playground/tables/self_test.cpp
- cpp/apps/primitives_gallery/playground/tables/tql.cpp
- cpp/apps/primitives_gallery/playground/tables/viz.cpp
- cpp/apps/primitives_gallery/playground/tables/viz.h
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:50:34 +02:00

239 lines
11 KiB
Markdown

---
id: 0080
title: tables playground — LLM "Ask AI" + TQL/SQL emit (fase 11)
status: partial
priority: medium
created: 2026-05-12
updated: 2026-05-13
notes: pure layer + LLM client + Ask AI modal DONE. DuckDB adapter v2 (opcional, build flag FN_TQL_DUCKDB=1)
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<std::string> params; // bound values (?-placeholders)
std::vector<std::string> 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<TableInput>& 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<active>`.
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: <razon>"`.
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<std::string>& params,
const std::vector<TableInput>& 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.