<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Martin Palopoli</title>
    <description>The latest articles on DEV Community by Martin Palopoli (@martin_palopoli).</description>
    <link>https://dev.to/martin_palopoli</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1593262%2F2a20804d-cfef-442e-ba10-0cf77928f348.jpg</url>
      <title>DEV Community: Martin Palopoli</title>
      <link>https://dev.to/martin_palopoli</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/martin_palopoli"/>
    <language>en</language>
    <item>
      <title>Del repo a producción con un solo comando: cómo Fitz hace del deployment una feature del lenguaje</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Thu, 11 Jun 2026 13:57:00 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/del-repo-a-produccion-con-un-solo-comando-como-fitz-hace-del-deployment-una-feature-del-lenguaje-264c</link>
      <guid>https://dev.to/martin_palopoli/del-repo-a-produccion-con-un-solo-comando-como-fitz-hace-del-deployment-una-feature-del-lenguaje-264c</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Un recorrido por la historia de deployment de Fitz — healthchecks, secrets como tipos opacos, observability con OpenTelemetry, Dockerfiles autogenerados y &lt;code&gt;fitz deploy&lt;/code&gt;. Production-ready no es una checklist, es sintaxis.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  La historia de deployment que la mayoría de los lenguajes no cuenta
&lt;/h2&gt;

&lt;p&gt;El primer 80% de un servicio es divertido: rutas, types, lógica de negocio, tests. El último 20% es la parte que efectivamente entrega la cosa, y ahí es donde todo el mundo pega con cinta cinco herramientas distintas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Una librería de healthcheck estilo &lt;code&gt;psutil&lt;/code&gt; porque Kubernetes quiere &lt;code&gt;/healthz&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;python-decouple&lt;/code&gt; o &lt;code&gt;pydantic-settings&lt;/code&gt; para env vars, más tu propia clase &lt;code&gt;Secret&lt;/code&gt; que con suerte no termina en logs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;opentelemetry-instrumentation-fastapi&lt;/code&gt; más &lt;code&gt;opentelemetry-exporter-otlp-proto-http&lt;/code&gt; más &lt;code&gt;opentelemetry-instrumentation-sqlalchemy&lt;/code&gt; más cualquiera sea la combinación correcta de versiones este mes.&lt;/li&gt;
&lt;li&gt;Un Dockerfile copiado de un post de blog, con tres cosas mal para tu setup.&lt;/li&gt;
&lt;li&gt;Un &lt;code&gt;docker-compose.yml&lt;/code&gt; que está bien excepto por el env var que tiene que venir de &lt;code&gt;.env.production&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Un &lt;code&gt;Makefile&lt;/code&gt; o &lt;code&gt;justfile&lt;/code&gt; con los comandos reales, o un YAML de CI que hace el equivalente.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Llevo diez años haciendo esto en cada proyecto. Es la parte donde el lenguaje deja de ayudarte. &lt;strong&gt;Fitz se niega a hacer eso.&lt;/strong&gt; Deployment está en el lenguaje.&lt;/p&gt;

&lt;p&gt;Acá está el stack completo de producción en Fitz, y qué reemplaza cada pieza.&lt;/p&gt;

&lt;h2&gt;
  
  
  Health checks como decoradores
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(43928)
fn main() =&amp;gt; 0

@healthz
fn liveness() -&amp;gt; Bool =&amp;gt; true

@readyz
async fn readiness(db: DbConn) -&amp;gt; Bool {
    return match db.exec("SELECT 1").await {
        Ok(_) =&amp;gt; true,
        Err(_) =&amp;gt; false,
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/healthz&lt;/code&gt; y &lt;code&gt;/readyz&lt;/code&gt; &lt;strong&gt;se auto-montan&lt;/strong&gt; en el router HTTP. Nada para importar, ninguna librería para configurar. El return value de la función determina el HTTP status (200 si true, 503 si false). Kubernetes contento.&lt;/p&gt;

&lt;p&gt;El compilador también hace cumplir el shape: la función retorna &lt;code&gt;Bool&lt;/code&gt; o &lt;code&gt;Result&amp;lt;Bool&amp;gt;&lt;/code&gt;, toma un &lt;code&gt;DbConn&lt;/code&gt; si lo necesita (el runtime lo inyecta). Si te olvidás del &lt;code&gt;@readyz&lt;/code&gt; entero, el endpoint de readiness simplemente no existe — no hay librería que "casi configures" mal.&lt;/p&gt;

&lt;p&gt;En Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# requirements.txt: starlette-healthcheck, pyhealthcheck, ...
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;healthcheck&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HealthCheck&lt;/span&gt;
&lt;span class="n"&gt;health&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HealthCheck&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;db_check&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db_check&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/healthz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# acordate de montarlo
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@readyz
async fn readiness(db: DbConn) -&amp;gt; Bool {
    return match db.exec("SELECT 1").await { Ok(_) =&amp;gt; true, Err(_) =&amp;gt; false }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eso es todo. Misma semántica, sin librería, sin paso de mounting, sin docs que tenés que volver a leer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets como tipos opacos
&lt;/h2&gt;

&lt;p&gt;El bug que más vi en código de producción: alguien loguea la password, la API key, el JWT secret. El logger entrega a Loki / Splunk / Sentry / lo que sea. El secret está ahora en logs de producción. Todo el mundo está de acuerdo en que esto es malo. Nadie tiene una buena respuesta en las librerías estándar — &lt;code&gt;os.environ["FOO"]&lt;/code&gt; es solo un string. &lt;code&gt;pydantic.SecretStr&lt;/code&gt; existe pero tenés que opt-in en todos lados y la gente se olvida.&lt;/p&gt;

&lt;p&gt;Fitz hace de los secrets un tipo de primera clase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let JWT_SECRET: Secret&amp;lt;Str&amp;gt; = secret("JWT_SECRET")
let DB_URL: Secret&amp;lt;Str&amp;gt; = secret("DATABASE_URL")
let LOG_LEVEL: Str = config("LOG_LEVEL", "info")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres cosas siguen del hecho de que &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; sea un tipo:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;print(JWT_SECRET)&lt;/code&gt; imprime `"&lt;/strong&gt;&lt;em&gt;"`&lt;/em&gt;*. El trait Display redacta el valor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La serialización JSON redacta&lt;/strong&gt;. &lt;code&gt;log.info("config", { jwt: JWT_SECRET })&lt;/code&gt; envía &lt;code&gt;"jwt": "***"&lt;/code&gt; a tu sistema de observability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hay exactamente una forma de exponer el valor&lt;/strong&gt;: &lt;code&gt;JWT_SECRET.expose()&lt;/code&gt;. Explícita, grepeable. El code review puede auditar cada call site en segundos.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;config("LOG_LEVEL", "info")&lt;/code&gt; es el hermano no-secret. Mismo lookup de env var, mismo default, tipo &lt;code&gt;Str&lt;/code&gt; plano — sin redacción porque no es sensible. El sistema de tipos hace la distinción obvia en lugar de depender de convenciones de nombres.&lt;/p&gt;

&lt;p&gt;Combinado con el migrator (Parte 2), los secrets de Postgres viven en &lt;code&gt;Secret&amp;lt;Str&amp;gt;&lt;/code&gt; desde el momento en que entran al binario hasta que se entregan al driver. No hay variable string con la password dando vueltas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability con un solo env var
&lt;/h2&gt;

&lt;p&gt;Tracing y métricas son las partes de observability de producción que todo el mundo quiere y nadie quiere configurar. El SDK Python de OpenTelemetry actualmente te pide que:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Instales &lt;code&gt;opentelemetry-api&lt;/code&gt;, &lt;code&gt;opentelemetry-sdk&lt;/code&gt;, el exporter para tu protocolo, y un paquete de instrumentation por cada librería que uses.&lt;/li&gt;
&lt;li&gt;Configures el tracer provider, el meter provider, los resource attributes, el sampler.&lt;/li&gt;
&lt;li&gt;Lo enganches en un hook de startup.&lt;/li&gt;
&lt;li&gt;Esperes que ninguna de las versiones de esos paquetes se haya roto entre releases de Python.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;En Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://jaeger:4318 ./mybin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esa es la integración. Cada request HTTP abre un span que exporta a OTLP. El span lleva &lt;code&gt;http.method&lt;/code&gt;, &lt;code&gt;http.target&lt;/code&gt; (template de la ruta, no path con params), &lt;code&gt;http.status_code&lt;/code&gt;, &lt;code&gt;duration_ms&lt;/code&gt;. El &lt;code&gt;trace_id&lt;/code&gt; y &lt;code&gt;span_id&lt;/code&gt; se propagan a cada &lt;code&gt;log.info(...)&lt;/code&gt; adentro del handler — cuando grepeás Jaeger por trace_id, encontrás cada log line relacionado en todos los servicios al instante.&lt;/p&gt;

&lt;p&gt;Cuando el env var &lt;strong&gt;no&lt;/strong&gt; está seteado: cero overhead, cero llamadas de red, ninguna tarea de exporter corriendo.&lt;/p&gt;

&lt;p&gt;Podés opt-out por ruta:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(observability=false)
fn main() =&amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Podés agregar spans explícitos adentro de la lógica de negocio:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@trace(name="process_order")
@metric(name="orders")
async fn process(order: Order) -&amp;gt; Result&amp;lt;Receipt&amp;gt; {
    let validated = validate(order)?
    let charged = charge(validated).await?
    return Ok(receipt_for(charged))
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@trace&lt;/code&gt; abre un &lt;code&gt;tracing::info_span!&lt;/code&gt;. &lt;code&gt;@metric&lt;/code&gt; registra un histograma &lt;code&gt;&amp;lt;name&amp;gt;_duration_seconds&lt;/code&gt; y un counter &lt;code&gt;&amp;lt;name&amp;gt;_calls_total&lt;/code&gt;, populados al hacer drop del scope de la función — funciona con paths &lt;code&gt;return&lt;/code&gt; explícitos, sin código muerto.&lt;/p&gt;

&lt;p&gt;Las métricas también se exponen en &lt;code&gt;/metrics&lt;/code&gt; (formato Prometheus) si lo activás:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(prometheus=true)
fn main() =&amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Para tiendas OpenTelemetry-native, el exporter OTLP envía también las métricas — ambos backends funcionan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logs estructurados con auto-correlación
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;log.info("user.signup", {
    user_id: user.id,
    email: user.email,
    plan: "free",
})
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eso emite una línea JSON a stderr con &lt;code&gt;timestamp&lt;/code&gt;, &lt;code&gt;level&lt;/code&gt;, &lt;code&gt;msg&lt;/code&gt;, los fields explícitos, y &lt;strong&gt;automáticamente&lt;/strong&gt; el &lt;code&gt;trace_id&lt;/code&gt;/&lt;code&gt;span_id&lt;/code&gt; del request HTTP actual. Sin llamada a &lt;code&gt;tracer.get_current_span()&lt;/code&gt;. El wrapper HTTP setea el contexto; el logger lo lee. Correlacionás logs y traces por &lt;code&gt;trace_id&lt;/code&gt; sin pensar en eso.&lt;/p&gt;

&lt;p&gt;Si valores &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; aparecen en los kwargs, se redactan antes de serializar. No hay forma de loguear accidentalmente un secret salvo escribir &lt;code&gt;.expose()&lt;/code&gt; explícito.&lt;/p&gt;

&lt;p&gt;Pretty-print en dev, JSON en producción:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin
2026-06-05 14:23:11 INFO http.access &lt;span class="nv"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;GET &lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/users/&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;200 &lt;span class="nv"&gt;duration_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;12 &lt;span class="nv"&gt;trace_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ab12cd34...
2026-06-05 14:23:11 INFO user.lookup &lt;span class="nv"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;42 &lt;span class="nv"&gt;found&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="nv"&gt;$ FITZ_LOG_FORMAT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json ./mybin
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"timestamp"&lt;/span&gt;:&lt;span class="s2"&gt;"2026-06-05T14:23:11Z"&lt;/span&gt;,&lt;span class="s2"&gt;"level"&lt;/span&gt;:&lt;span class="s2"&gt;"INFO"&lt;/span&gt;,&lt;span class="s2"&gt;"msg"&lt;/span&gt;:&lt;span class="s2"&gt;"http.access"&lt;/span&gt;,&lt;span class="s2"&gt;"method"&lt;/span&gt;:&lt;span class="s2"&gt;"GET"&lt;/span&gt;,&lt;span class="s2"&gt;"target"&lt;/span&gt;:&lt;span class="s2"&gt;"/users/{id}"&lt;/span&gt;,&lt;span class="s2"&gt;"status"&lt;/span&gt;:200,&lt;span class="s2"&gt;"duration_ms"&lt;/span&gt;:12,&lt;span class="s2"&gt;"trace_id"&lt;/span&gt;:&lt;span class="s2"&gt;"ab12cd34..."&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El happy path de Loki/Datadog/Splunk es el mismo JSON estés usando OpenTelemetry o no. Ningún agente re-parsea prefijos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature flags como decorador
&lt;/h2&gt;

&lt;p&gt;Los feature flags estilo &lt;code&gt;unleash&lt;/code&gt;/&lt;code&gt;launchdarkly&lt;/code&gt; son normalmente una llamada de servicio por evaluación. Para la mayoría de los proyectos, la respuesta correcta es mucho más simple: una flag en tu config, un override por env var, un 404 si la flag está off.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@flag("new-checkout")
@post("/v2/checkout")
fn v2_checkout(body: Cart) -&amp;gt; Receipt { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dos fuentes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sección &lt;code&gt;[flags]&lt;/code&gt; en &lt;code&gt;fitz.toml&lt;/code&gt; — defaults compile-time horneados al binario.&lt;/li&gt;
&lt;li&gt;Env vars &lt;code&gt;FITZ_FLAG_&amp;lt;NAME&amp;gt;&lt;/code&gt; — override runtime sin recompilar.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Default es &lt;code&gt;false&lt;/code&gt; (fail-safe). Cuando la flag está off, el handler HTTP/WS retorna 404 — y la ruta está gateada &lt;strong&gt;antes&lt;/strong&gt; de que corran los middlewares y la auth, así que no gastás ciclos en algo que el user no puede alcanzar de todas formas.&lt;/p&gt;

&lt;p&gt;Adentro de la lógica de negocio:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if flag("show-experimental-banner") {
    show_banner()
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;flags.list()&lt;/code&gt; enumera flags conocidas (config + env). &lt;code&gt;flags.is_enabled("name")&lt;/code&gt; es el alias.&lt;/p&gt;

&lt;p&gt;El modelo no está tratando de reemplazar a LaunchDarkly. Está tratando de eliminar la excusa para commitear &lt;code&gt;if user.id == 42&lt;/code&gt; y gatear features para testing. Servicio de feature flags real cuando necesitás targeting, porcentajes, audit log. Flags built-in cuando solo querés un kill switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dockerfile generado de tu AST
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz docker init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lee tu &lt;code&gt;main.fitz&lt;/code&gt; y escribe tres archivos:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Dockerfile&lt;/code&gt; — multi-stage. El builder usa la imagen toolchain de Fitz. La stage de runtime es &lt;code&gt;gcr.io/distroless/cc-debian12&lt;/code&gt; (o &lt;code&gt;python:3.12-slim-bookworm&lt;/code&gt; si hay un &lt;code&gt;from python import ...&lt;/code&gt; en tu código — distroless no puede hostear CPython).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.dockerignore&lt;/code&gt; — defaults que matchean los smell tests (&lt;code&gt;target/&lt;/code&gt;, &lt;code&gt;.git/&lt;/code&gt;, &lt;code&gt;.env*&lt;/code&gt;, &lt;code&gt;__pycache__/&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker-compose.yml&lt;/code&gt; — tu app más la infraestructura que necesita. &lt;code&gt;db.connect(...)&lt;/code&gt; en tu código agrega &lt;code&gt;postgres:16-alpine&lt;/code&gt; con healthcheck y volumen &lt;code&gt;pgdata&lt;/code&gt;. &lt;code&gt;@server(8080)&lt;/code&gt; setea &lt;code&gt;EXPOSE 8080&lt;/code&gt;. &lt;code&gt;@cron&lt;/code&gt; agrega &lt;code&gt;restart: unless-stopped&lt;/code&gt;. Healthcheck contra &lt;code&gt;/healthz&lt;/code&gt; porque hay un decorador &lt;code&gt;@healthz&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La detección es &lt;strong&gt;AST-only&lt;/strong&gt; (~50ms). No ejecuta tu programa, no toca el disco, no probe ports. Lee el árbol sintáctico, busca decoradores y calls landmarks, llena el template correcto.&lt;/p&gt;

&lt;p&gt;Esta es la parte de deployment que tuve mal en cada proyecto: el Dockerfile que está casi bien pero no tiene libpq para Postgres, el compose que está casi bien pero montea el volumen equivocado. &lt;code&gt;fitz docker init&lt;/code&gt; lo agarra bien la primera vez porque el AST le dice qué necesitás.&lt;/p&gt;

&lt;p&gt;Los archivos se commitean. Editalos cuando los necesites:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;fitz docker init        &lt;span class="c"&gt;# generar&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="c"&gt;# editás Dockerfile, editás docker-compose.yml — son archivos normales&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git add Dockerfile docker-compose.yml .dockerignore
&lt;span class="nv"&gt;$ &lt;/span&gt;git commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fitz docker build&lt;/code&gt; es el wrapper fino que corre &lt;code&gt;docker build -t &amp;lt;pkg-name&amp;gt;:latest .&lt;/code&gt; con el working directory correcto.&lt;/p&gt;

&lt;h2&gt;
  
  
  fitz deploy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Build de imagen y push al registry (saltá el push con --no-push).&lt;/span&gt;
fitz deploy docker &lt;span class="nt"&gt;--tag&lt;/span&gt; mycorp/api:v1

&lt;span class="c"&gt;# Levantá el stack compose local (con -d por default; --no-detach para foreground).&lt;/span&gt;
fitz deploy compose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Estos &lt;strong&gt;no son&lt;/strong&gt; herramientas nuevas. Son wrappers finos sobre &lt;code&gt;docker build&lt;/code&gt;/&lt;code&gt;docker push&lt;/code&gt; y &lt;code&gt;docker compose up&lt;/code&gt;. El punto no es inventar un sistema de deploy; el punto es que el comando de deploy existe en la misma toolchain que &lt;code&gt;fitz build&lt;/code&gt;. No tenés que mantener un &lt;code&gt;deploy.sh&lt;/code&gt; al lado de tu código.&lt;/p&gt;

&lt;p&gt;Targets en el MVP: &lt;code&gt;docker&lt;/code&gt; y &lt;code&gt;compose&lt;/code&gt;. Targets explícitamente &lt;strong&gt;fuera&lt;/strong&gt; del MVP: &lt;code&gt;fly&lt;/code&gt;, &lt;code&gt;railway&lt;/code&gt;, &lt;code&gt;k8s&lt;/code&gt;. Para esos, corré &lt;code&gt;flyctl deploy&lt;/code&gt; / &lt;code&gt;railway up&lt;/code&gt; / &lt;code&gt;kubectl apply&lt;/code&gt; directo — ya son buenas herramientas, Fitz no necesita re-envolverlas. Si aparece demanda por un target específico más adelante, se puede agregar — el helper crate son &lt;code&gt;~430 líneas&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cómo se ve end-to-end
&lt;/h2&gt;

&lt;p&gt;Un servicio real en Fitz hoy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(8080, prometheus=true)
fn main() =&amp;gt; 0

let DB_URL: Secret&amp;lt;Str&amp;gt; = secret("DATABASE_URL")
let JWT_SECRET: Secret&amp;lt;Str&amp;gt; = secret("JWT_SECRET")
let db = db.connect(DB_URL.expose())

@healthz
fn liveness() -&amp;gt; Bool =&amp;gt; true

@readyz
async fn readiness() -&amp;gt; Bool {
    return match db.exec("SELECT 1").await { Ok(_) =&amp;gt; true, Err(_) =&amp;gt; false }
}

@table("users")
type User { @primary id: Int, email: Str, name: Str, role: Str }

@auth_provider
fn check_token(headers: Map&amp;lt;Str, Str&amp;gt;) -&amp;gt; Result&amp;lt;User&amp;gt; { /* verify JWT */ }

@authenticated
@get("/me")
fn me(user: User) -&amp;gt; User =&amp;gt; user

@trace(name="charge")
@metric(name="charges")
@requires("billing")
@post("/charge")
async fn charge(body: ChargeRequest, user: User) -&amp;gt; Result&amp;lt;Receipt&amp;gt; {
    log.info("charge.attempt", { user_id: user.id, amount: body.amount })
    let receipt = stripe_charge(body).await?
    return Ok(receipt)
}

@cron("0 0 3 * * *", retry={max: 5, backoff: "exponential"}, store=db)
async fn cleanup_expired_tokens() {
    auth.cleanup_expired(db).await?
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lo deployás:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz docker init    &lt;span class="c"&gt;# genera Dockerfile + compose con postgres + healthcheck&lt;/span&gt;
git add &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"deploy setup"&lt;/span&gt;
fitz deploy docker &lt;span class="nt"&gt;--tag&lt;/span&gt; mycorp/api:v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En producción:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres://... &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://collector:4318 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:8080 &lt;span class="se"&gt;\&lt;/span&gt;
  mycorp/api:v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ese binario:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Auto-montea &lt;code&gt;/healthz&lt;/code&gt;, &lt;code&gt;/readyz&lt;/code&gt;, &lt;code&gt;/openapi.json&lt;/code&gt;, &lt;code&gt;/docs&lt;/code&gt;, &lt;code&gt;/metrics&lt;/code&gt;, &lt;code&gt;/asyncapi.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Exporta cada request HTTP como span OpenTelemetry con &lt;code&gt;trace_id&lt;/code&gt; propagado a logs.&lt;/li&gt;
&lt;li&gt;Valida JWTs con passwords hasheadas Argon2id.&lt;/li&gt;
&lt;li&gt;Corre cleanup scheduled con retry persistente sobre las tablas &lt;code&gt;fitz_cron_jobs&lt;/code&gt;/&lt;code&gt;fitz_cron_runs&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Redacta cada &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; de logs, JSON responses, y fields estructurados.&lt;/li&gt;
&lt;li&gt;Maneja SIGTERM gracefully (drainando requests, después exit).&lt;/li&gt;
&lt;li&gt;Compila a ~18 MB de binario nativo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Escribiste ~50 líneas de código de negocio. El resto es el lenguaje haciendo lo que el lenguaje debería hacer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que todavía no está acá (honesto)
&lt;/h2&gt;

&lt;p&gt;Te dije la verdad en la Parte 1: un solo dev. La historia de deployment tiene gaps conocidos:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz deploy fly&lt;/code&gt;&lt;/strong&gt; / &lt;code&gt;fitz deploy railway&lt;/code&gt; / &lt;code&gt;fitz deploy k8s&lt;/code&gt; no están construidos. Usá &lt;code&gt;flyctl deploy&lt;/code&gt; o &lt;code&gt;railway up&lt;/code&gt; o &lt;code&gt;kubectl apply&lt;/code&gt; directo. Los CLIs nativos son excelentes — Fitz no necesita re-envolverlos hoy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config de sidecar log shipping&lt;/strong&gt; en el compose generado. Si querés Fluent Bit / Vector / Loki Promtail cableado, editá el compose a mano. El autogen cubre el shape del programa, no el backend de observability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limits de CPU/memoria en compose&lt;/strong&gt;. El MVP no los setea — deploys de producción (compose stack a un server) deberían agregar &lt;code&gt;deploy.resources.limits&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SBOM / firma de imagen&lt;/strong&gt;. No se autogenera. La imagen es output de &lt;code&gt;docker build&lt;/code&gt;. Firmá con &lt;code&gt;cosign&lt;/code&gt; si lo necesitás.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Todo lo demás de arriba está en v0.15.0 hoy, con tests, con paridad bit-a-bit &lt;code&gt;fitz run&lt;/code&gt; ↔ &lt;code&gt;fitz build&lt;/code&gt;, con ejemplos en los docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Por qué importa
&lt;/h2&gt;

&lt;p&gt;El split 80/20 que describí al principio — 80% código divertido, 20% pegoteo de producción — es un impuesto sobre cada lenguaje diseñado antes de 2015. Los lenguajes que diseñaron para producción desde el día uno (Go es el ejemplo obvio) cambiaron otras cosas para llegar ahí. Fitz está tratando de mantener el feel gradual-typed, expression-rich, async-first de Python — y aún así taxar producción con cero peso extra.&lt;/p&gt;

&lt;p&gt;Si pasaste por una semana de deploy y sentiste "esto no debería requerir cinco pestañas de Stack Overflow", ese es el sentimiento al que estoy construyendo para eliminar.&lt;/p&gt;

&lt;p&gt;Probalo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Linux / macOS / WSL&lt;/span&gt;
curl &lt;span class="nt"&gt;-sSf&lt;/span&gt; https://thegreekman76.github.io/fitz/install.sh | sh

&lt;span class="c"&gt;# Windows (PowerShell)&lt;/span&gt;
irm https://thegreekman76.github.io/fitz/install.ps1 | iex

&lt;span class="c"&gt;# Reabrí la terminal, después:&lt;/span&gt;
fitz new mi-api-prod &lt;span class="nt"&gt;--http&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;mi-api-prod
&lt;span class="c"&gt;# Editá main.fitz para sumar @healthz, @table, una ruta HTTP&lt;/span&gt;
fitz docker init
fitz deploy compose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Para VSCode (recomendado — hover con tipos, autocomplete, signature help): bajá el &lt;code&gt;fitz-lang-&amp;lt;plataforma&amp;gt;.vsix&lt;/code&gt; desde la &lt;a href="https://github.com/Thegreekman76/fitz/releases" rel="noopener noreferrer"&gt;página de releases&lt;/a&gt; y &lt;code&gt;code --install-extension fitz-lang-&amp;lt;plataforma&amp;gt;.vsix --force&lt;/code&gt;. El Language Server viene incluido.&lt;/p&gt;

&lt;p&gt;Vas a tener un servicio con healthchecks, observability, y Docker compose en tu proyecto en menos de cinco minutos. Avisame qué se rompió.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Docs y curso&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Capítulo de la guía sobre deployment&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz/guide/#35-deployment&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Roadmap&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/docs/roadmap.md" rel="noopener noreferrer"&gt;docs/roadmap.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CHANGELOG&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;CHANGELOG.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Issues&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/issues" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/issues&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hasta la próxima.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>opensource</category>
      <category>observability</category>
    </item>
    <item>
      <title>From repo to production in one command: how Fitz makes deployment a language feature</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Thu, 11 Jun 2026 13:56:33 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/from-repo-to-production-in-one-command-how-fitz-makes-deployment-a-language-feature-oke</link>
      <guid>https://dev.to/martin_palopoli/from-repo-to-production-in-one-command-how-fitz-makes-deployment-a-language-feature-oke</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A walk through the deployment story of Fitz — healthchecks, secrets as opaque types, OpenTelemetry observability, autogenerated Dockerfiles, and &lt;code&gt;fitz deploy&lt;/code&gt;. Production-ready isn't a checklist, it's syntax.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The deployment story most languages don't tell
&lt;/h2&gt;

&lt;p&gt;The first 80% of a service is fun: routes, types, business logic, tests. The last 20% is the part that ships the thing, and that's where everyone tapes together five different tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Some &lt;code&gt;psutil&lt;/code&gt;-style healthcheck library because Kubernetes wants &lt;code&gt;/healthz&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;python-decouple&lt;/code&gt; or &lt;code&gt;pydantic-settings&lt;/code&gt; for env vars, plus your own &lt;code&gt;Secret&lt;/code&gt; class that hopefully doesn't end up in logs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;opentelemetry-instrumentation-fastapi&lt;/code&gt; plus &lt;code&gt;opentelemetry-exporter-otlp-proto-http&lt;/code&gt; plus &lt;code&gt;opentelemetry-instrumentation-sqlalchemy&lt;/code&gt; plus whatever the right combo of versions is this month.&lt;/li&gt;
&lt;li&gt;A Dockerfile copy-pasted from a blog post, with three things wrong for your setup.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;docker-compose.yml&lt;/code&gt; that's right except for the env var that has to come from &lt;code&gt;.env.production&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;Makefile&lt;/code&gt; or &lt;code&gt;justfile&lt;/code&gt; with the actual commands, or a CI YAML that does the equivalent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I've done this every project for ten years. It's the part where the language stops helping you. &lt;strong&gt;Fitz refuses to do that.&lt;/strong&gt; Deployment is in the language.&lt;/p&gt;

&lt;p&gt;Here's the full production stack in Fitz, and what each piece replaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Health checks as decorators
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(43928)
fn main() =&amp;gt; 0

@healthz
fn liveness() -&amp;gt; Bool =&amp;gt; true

@readyz
async fn readiness(db: DbConn) -&amp;gt; Bool {
    return match db.exec("SELECT 1").await {
        Ok(_) =&amp;gt; true,
        Err(_) =&amp;gt; false,
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/healthz&lt;/code&gt; and &lt;code&gt;/readyz&lt;/code&gt; &lt;strong&gt;auto-mount&lt;/strong&gt; on the HTTP router. There's nothing to import, no library to configure. The return value of the function determines the HTTP status (200 if true, 503 if false). Kubernetes is happy.&lt;/p&gt;

&lt;p&gt;The compiler also enforces shape: the function returns &lt;code&gt;Bool&lt;/code&gt; or &lt;code&gt;Result&amp;lt;Bool&amp;gt;&lt;/code&gt;, takes a &lt;code&gt;DbConn&lt;/code&gt; if it needs one (the runtime injects it). If you forget &lt;code&gt;@readyz&lt;/code&gt; entirely, the readiness endpoint just doesn't exist — there's no library to "almost configure" wrong.&lt;/p&gt;

&lt;p&gt;In Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# requirements.txt: starlette-healthcheck, pyhealthcheck, ...
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;healthcheck&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HealthCheck&lt;/span&gt;
&lt;span class="n"&gt;health&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HealthCheck&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;db_check&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db_check&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/healthz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# remember to mount it
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@readyz
async fn readiness(db: DbConn) -&amp;gt; Bool {
    return match db.exec("SELECT 1").await { Ok(_) =&amp;gt; true, Err(_) =&amp;gt; false }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Same semantics, no library, no mount step, no docs you have to re-read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets as opaque types
&lt;/h2&gt;

&lt;p&gt;The bug I've seen the most in production code: somebody logs the password, the API key, the JWT secret. The logger ships to Loki / Splunk / Sentry / whatever. The secret is now in production logs. Everyone agrees this is bad. Nobody has a good answer in stock libraries — &lt;code&gt;os.environ["FOO"]&lt;/code&gt; is just a string. &lt;code&gt;pydantic.SecretStr&lt;/code&gt; exists but you have to opt in everywhere and people forget.&lt;/p&gt;

&lt;p&gt;Fitz makes secrets a first-class type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let JWT_SECRET: Secret&amp;lt;Str&amp;gt; = secret("JWT_SECRET")
let DB_URL: Secret&amp;lt;Str&amp;gt; = secret("DATABASE_URL")
let LOG_LEVEL: Str = config("LOG_LEVEL", "info")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things follow from &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; being a type:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;print(JWT_SECRET)&lt;/code&gt; prints `"&lt;/strong&gt;&lt;em&gt;"`&lt;/em&gt;*. The Display trait redacts the value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON serialization redacts&lt;/strong&gt;. &lt;code&gt;log.info("config", { jwt: JWT_SECRET })&lt;/code&gt; ships &lt;code&gt;"jwt": "***"&lt;/code&gt; to your observability system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There's exactly one way to expose the value&lt;/strong&gt;: &lt;code&gt;JWT_SECRET.expose()&lt;/code&gt;. Explicit, grep-able. Code review can audit every call site in seconds.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;config("LOG_LEVEL", "info")&lt;/code&gt; is the non-secret sibling. Same env var lookup, same default, plain &lt;code&gt;Str&lt;/code&gt; type — no redaction because it's not sensitive. The type system makes the distinction obvious instead of relying on naming conventions.&lt;/p&gt;

&lt;p&gt;Combined with the migrator (Part 2), Postgres secrets live in &lt;code&gt;Secret&amp;lt;Str&amp;gt;&lt;/code&gt; from the moment they enter the binary until they're handed to the driver. There's no string variable holding the password sitting around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability with one env var
&lt;/h2&gt;

&lt;p&gt;Tracing and metrics are the parts of production observability that everyone wants and nobody wants to set up. The Python OpenTelemetry SDK currently requires you to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install &lt;code&gt;opentelemetry-api&lt;/code&gt;, &lt;code&gt;opentelemetry-sdk&lt;/code&gt;, the exporter for your protocol, and one instrumentation package per library you use.&lt;/li&gt;
&lt;li&gt;Configure the tracer provider, the meter provider, the resource attributes, the sampler.&lt;/li&gt;
&lt;li&gt;Wire it up in a startup hook.&lt;/li&gt;
&lt;li&gt;Hope none of the versions of those packages broke between Python releases.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://jaeger:4318 ./mybin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the integration. Every HTTP request opens a span that exports to OTLP. The span carries &lt;code&gt;http.method&lt;/code&gt;, &lt;code&gt;http.target&lt;/code&gt; (route template, not path with params), &lt;code&gt;http.status_code&lt;/code&gt;, &lt;code&gt;duration_ms&lt;/code&gt;. The &lt;code&gt;trace_id&lt;/code&gt; and &lt;code&gt;span_id&lt;/code&gt; are propagated to every &lt;code&gt;log.info(...)&lt;/code&gt; inside the handler — when you grep Jaeger by trace_id, you instantly find every related log line across services.&lt;/p&gt;

&lt;p&gt;When the env var is &lt;strong&gt;not&lt;/strong&gt; set: zero overhead, zero network calls, no exporter task running.&lt;/p&gt;

&lt;p&gt;You can opt out per route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(observability=false)
fn main() =&amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can add explicit spans inside business logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@trace(name="process_order")
@metric(name="orders")
async fn process(order: Order) -&amp;gt; Result&amp;lt;Receipt&amp;gt; {
    let validated = validate(order)?
    let charged = charge(validated).await?
    return Ok(receipt_for(charged))
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@trace&lt;/code&gt; opens a &lt;code&gt;tracing::info_span!&lt;/code&gt;. &lt;code&gt;@metric&lt;/code&gt; registers a &lt;code&gt;&amp;lt;name&amp;gt;_duration_seconds&lt;/code&gt; histogram and a &lt;code&gt;&amp;lt;name&amp;gt;_calls_total&lt;/code&gt; counter, populated on drop of the function scope — works with explicit &lt;code&gt;return&lt;/code&gt; paths, no dead code.&lt;/p&gt;

&lt;p&gt;Metrics also expose at &lt;code&gt;/metrics&lt;/code&gt; (Prometheus format) if you opt in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(prometheus=true)
fn main() =&amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For OpenTelemetry-native shops, the OTLP exporter sends metrics too — both backends work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Structured logs with auto-correlation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;log.info("user.signup", {
    user_id: user.id,
    email: user.email,
    plan: "free",
})
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That emits a JSON line to stderr with &lt;code&gt;timestamp&lt;/code&gt;, &lt;code&gt;level&lt;/code&gt;, &lt;code&gt;msg&lt;/code&gt;, the explicit fields, and &lt;strong&gt;automatically&lt;/strong&gt; the &lt;code&gt;trace_id&lt;/code&gt;/&lt;code&gt;span_id&lt;/code&gt; of the current HTTP request. No &lt;code&gt;tracer.get_current_span()&lt;/code&gt; call. The HTTP wrapper sets the context; the logger reads it. You correlate logs and traces by &lt;code&gt;trace_id&lt;/code&gt; without thinking about it.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; values appear in the kwargs, they're redacted before serialization. There is no way to accidentally log a secret short of writing &lt;code&gt;.expose()&lt;/code&gt; explicitly.&lt;/p&gt;

&lt;p&gt;Pretty-printed in dev, JSON in production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin
2026-06-05 14:23:11 INFO http.access &lt;span class="nv"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;GET &lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/users/&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;200 &lt;span class="nv"&gt;duration_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;12 &lt;span class="nv"&gt;trace_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ab12cd34...
2026-06-05 14:23:11 INFO user.lookup &lt;span class="nv"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;42 &lt;span class="nv"&gt;found&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="nv"&gt;$ FITZ_LOG_FORMAT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json ./mybin
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"timestamp"&lt;/span&gt;:&lt;span class="s2"&gt;"2026-06-05T14:23:11Z"&lt;/span&gt;,&lt;span class="s2"&gt;"level"&lt;/span&gt;:&lt;span class="s2"&gt;"INFO"&lt;/span&gt;,&lt;span class="s2"&gt;"msg"&lt;/span&gt;:&lt;span class="s2"&gt;"http.access"&lt;/span&gt;,&lt;span class="s2"&gt;"method"&lt;/span&gt;:&lt;span class="s2"&gt;"GET"&lt;/span&gt;,&lt;span class="s2"&gt;"target"&lt;/span&gt;:&lt;span class="s2"&gt;"/users/{id}"&lt;/span&gt;,&lt;span class="s2"&gt;"status"&lt;/span&gt;:200,&lt;span class="s2"&gt;"duration_ms"&lt;/span&gt;:12,&lt;span class="s2"&gt;"trace_id"&lt;/span&gt;:&lt;span class="s2"&gt;"ab12cd34..."&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Loki/Datadog/Splunk happy path is the same JSON whether you're using OpenTelemetry or not. No agent re-parses prefixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature flags as a decorator
&lt;/h2&gt;

&lt;p&gt;Feature flags from &lt;code&gt;unleash&lt;/code&gt;/&lt;code&gt;launchdarkly&lt;/code&gt; are usually a service call per evaluation. For most projects, the right answer is much simpler: a flag in your config, an env var override, a 404 if the flag is off.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@flag("new-checkout")
@post("/v2/checkout")
fn v2_checkout(body: Cart) -&amp;gt; Receipt { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;[flags]&lt;/code&gt; section in &lt;code&gt;fitz.toml&lt;/code&gt; — compile-time defaults baked into the binary.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FITZ_FLAG_&amp;lt;NAME&amp;gt;&lt;/code&gt; env vars — runtime override without recompiling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Default is &lt;code&gt;false&lt;/code&gt; (fail-safe). When the flag is off, the HTTP/WS handler returns 404 — and the route is gated &lt;strong&gt;before&lt;/strong&gt; middleware and auth run, so you don't waste cycles on something the user can't reach anyway.&lt;/p&gt;

&lt;p&gt;Inside business logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if flag("show-experimental-banner") {
    show_banner()
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;flags.list()&lt;/code&gt; enumerates known flags (config + env). &lt;code&gt;flags.is_enabled("name")&lt;/code&gt; is the alias.&lt;/p&gt;

&lt;p&gt;The model isn't trying to replace LaunchDarkly. It's trying to remove the excuse for committing &lt;code&gt;if user.id == 42&lt;/code&gt; to gate features for testing. Real feature flag service when you need targeting, percentages, audit log. Built-in flags when you just want a kill switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dockerfile generated from your AST
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz docker init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reads your &lt;code&gt;main.fitz&lt;/code&gt; and writes three files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Dockerfile&lt;/code&gt; — multi-stage. The builder uses the Fitz toolchain image. The runtime stage is &lt;code&gt;gcr.io/distroless/cc-debian12&lt;/code&gt; (or &lt;code&gt;python:3.12-slim-bookworm&lt;/code&gt; if there's a &lt;code&gt;from python import ...&lt;/code&gt; in your code — distroless can't host CPython).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.dockerignore&lt;/code&gt; — defaults that match the smell tests (&lt;code&gt;target/&lt;/code&gt;, &lt;code&gt;.git/&lt;/code&gt;, &lt;code&gt;.env*&lt;/code&gt;, &lt;code&gt;__pycache__/&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker-compose.yml&lt;/code&gt; — your app plus the infrastructure it needs. &lt;code&gt;db.connect(...)&lt;/code&gt; in your code adds &lt;code&gt;postgres:16-alpine&lt;/code&gt; with a healthcheck and a &lt;code&gt;pgdata&lt;/code&gt; volume. &lt;code&gt;@server(8080)&lt;/code&gt; sets &lt;code&gt;EXPOSE 8080&lt;/code&gt;. &lt;code&gt;@cron&lt;/code&gt; adds &lt;code&gt;restart: unless-stopped&lt;/code&gt;. Healthcheck against &lt;code&gt;/healthz&lt;/code&gt; because there's an &lt;code&gt;@healthz&lt;/code&gt; decorator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The detection is &lt;strong&gt;AST-only&lt;/strong&gt; (~50ms). It doesn't run your program, doesn't poke at the disk, doesn't probe ports. It reads the syntax tree, looks for landmark decorators and calls, fills in the right template.&lt;/p&gt;

&lt;p&gt;This is the part of deployment that I've gotten wrong every project: the Dockerfile that's almost right but doesn't have the libpq for Postgres, the compose that's almost right but mounts the wrong volume. &lt;code&gt;fitz docker init&lt;/code&gt; gets it right the first time because the AST tells it what you need.&lt;/p&gt;

&lt;p&gt;The files are committed. Edit them when you need to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;fitz docker init        &lt;span class="c"&gt;# generate&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="c"&gt;# edit Dockerfile, edit docker-compose.yml — these are normal files&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git add Dockerfile docker-compose.yml .dockerignore
&lt;span class="nv"&gt;$ &lt;/span&gt;git commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fitz docker build&lt;/code&gt; is the thin wrapper that runs &lt;code&gt;docker build -t &amp;lt;pkg-name&amp;gt;:latest .&lt;/code&gt; with the right working directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  fitz deploy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Build the image and push to a registry (skip the push with --no-push).&lt;/span&gt;
fitz deploy docker &lt;span class="nt"&gt;--tag&lt;/span&gt; mycorp/api:v1

&lt;span class="c"&gt;# Bring up the compose stack locally (with -d by default; --no-detach for foreground).&lt;/span&gt;
fitz deploy compose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are &lt;strong&gt;not&lt;/strong&gt; new tools. They're thin wrappers over &lt;code&gt;docker build&lt;/code&gt;/&lt;code&gt;docker push&lt;/code&gt; and &lt;code&gt;docker compose up&lt;/code&gt;. The point isn't to invent a deploy system; the point is that the deploy command exists in the same toolchain as &lt;code&gt;fitz build&lt;/code&gt;. You don't have to maintain a &lt;code&gt;deploy.sh&lt;/code&gt; next to your code.&lt;/p&gt;

&lt;p&gt;Targets in the MVP: &lt;code&gt;docker&lt;/code&gt; and &lt;code&gt;compose&lt;/code&gt;. Targets explicitly &lt;strong&gt;not&lt;/strong&gt; in the MVP: &lt;code&gt;fly&lt;/code&gt;, &lt;code&gt;railway&lt;/code&gt;, &lt;code&gt;k8s&lt;/code&gt;. For those, run &lt;code&gt;flyctl deploy&lt;/code&gt; / &lt;code&gt;railway up&lt;/code&gt; / &lt;code&gt;kubectl apply&lt;/code&gt; directly — they're already good tools, Fitz doesn't need to re-wrap them. If demand appears for a specific target later, it can be added — the helper crate is &lt;code&gt;~430 lines&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like end-to-end
&lt;/h2&gt;

&lt;p&gt;A real service in Fitz today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(8080, prometheus=true)
fn main() =&amp;gt; 0

let DB_URL: Secret&amp;lt;Str&amp;gt; = secret("DATABASE_URL")
let JWT_SECRET: Secret&amp;lt;Str&amp;gt; = secret("JWT_SECRET")
let db = db.connect(DB_URL.expose())

@healthz
fn liveness() -&amp;gt; Bool =&amp;gt; true

@readyz
async fn readiness() -&amp;gt; Bool {
    return match db.exec("SELECT 1").await { Ok(_) =&amp;gt; true, Err(_) =&amp;gt; false }
}

@table("users")
type User { @primary id: Int, email: Str, name: Str, role: Str }

@auth_provider
fn check_token(headers: Map&amp;lt;Str, Str&amp;gt;) -&amp;gt; Result&amp;lt;User&amp;gt; { /* JWT verify */ }

@authenticated
@get("/me")
fn me(user: User) -&amp;gt; User =&amp;gt; user

@trace(name="charge")
@metric(name="charges")
@requires("billing")
@post("/charge")
async fn charge(body: ChargeRequest, user: User) -&amp;gt; Result&amp;lt;Receipt&amp;gt; {
    log.info("charge.attempt", { user_id: user.id, amount: body.amount })
    let receipt = stripe_charge(body).await?
    return Ok(receipt)
}

@cron("0 0 3 * * *", retry={max: 5, backoff: "exponential"}, store=db)
async fn cleanup_expired_tokens() {
    auth.cleanup_expired(db).await?
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz docker init    &lt;span class="c"&gt;# generates Dockerfile + compose with postgres + healthcheck&lt;/span&gt;
git add &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"deploy setup"&lt;/span&gt;
fitz deploy docker &lt;span class="nt"&gt;--tag&lt;/span&gt; mycorp/api:v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres://... &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://collector:4318 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:8080 &lt;span class="se"&gt;\&lt;/span&gt;
  mycorp/api:v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That binary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Auto-mounts &lt;code&gt;/healthz&lt;/code&gt;, &lt;code&gt;/readyz&lt;/code&gt;, &lt;code&gt;/openapi.json&lt;/code&gt;, &lt;code&gt;/docs&lt;/code&gt;, &lt;code&gt;/metrics&lt;/code&gt;, &lt;code&gt;/asyncapi.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Exports every HTTP request as an OpenTelemetry span with &lt;code&gt;trace_id&lt;/code&gt; propagated to logs.&lt;/li&gt;
&lt;li&gt;Validates JWTs with Argon2id-hashed passwords.&lt;/li&gt;
&lt;li&gt;Runs scheduled cleanup with persistent retry over the &lt;code&gt;fitz_cron_jobs&lt;/code&gt;/&lt;code&gt;fitz_cron_runs&lt;/code&gt; tables.&lt;/li&gt;
&lt;li&gt;Redacts every &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; from logs, JSON responses, and structured fields.&lt;/li&gt;
&lt;li&gt;Handles SIGTERM gracefully (draining requests, then exit).&lt;/li&gt;
&lt;li&gt;Compiles to ~18 MB of native binary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You wrote ~50 lines of business code. The rest is the language doing what the language should do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's not in here yet (honest)
&lt;/h2&gt;

&lt;p&gt;I told you the truth in Part 1: one developer. The deployment story has known gaps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz deploy fly&lt;/code&gt;&lt;/strong&gt; / &lt;code&gt;fitz deploy railway&lt;/code&gt; / &lt;code&gt;fitz deploy k8s&lt;/code&gt; are not built. Use &lt;code&gt;flyctl deploy&lt;/code&gt; or &lt;code&gt;railway up&lt;/code&gt; or &lt;code&gt;kubectl apply&lt;/code&gt; directly. The native CLIs are excellent — Fitz doesn't need to re-wrap them today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sidecar log shipping config&lt;/strong&gt; in the generated compose. If you want Fluent Bit / Vector / Loki Promtail wired in, edit the compose by hand. The autogen covers the program shape, not the observability backend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU/memory limits in compose&lt;/strong&gt;. The MVP doesn't set them — production deploys (compose stack to a server) should add &lt;code&gt;deploy.resources.limits&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SBOM / image signing&lt;/strong&gt;. Not auto-generated. The image is just &lt;code&gt;docker build&lt;/code&gt; output. Sign with &lt;code&gt;cosign&lt;/code&gt; if you need it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else above is in v0.15.0 today, with tests, with bit-for-bit &lt;code&gt;fitz run&lt;/code&gt; ↔ &lt;code&gt;fitz build&lt;/code&gt; parity, with examples in the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters
&lt;/h2&gt;

&lt;p&gt;The 80/20 split I described at the top — 80% fun code, 20% production stapling — is a tax on every language designed before 2015. The languages that designed for production from day one (Go is the obvious example) traded other things to get there. Fitz is trying to keep the gradual-typed, expression-rich, async-first feel of Python — and still tax production with zero extra weight.&lt;/p&gt;

&lt;p&gt;If you've gone through a deploy week and felt "this should not require five Stack Overflow tabs", that's the feeling I'm building toward removing.&lt;/p&gt;

&lt;p&gt;Try it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Linux / macOS / WSL&lt;/span&gt;
curl &lt;span class="nt"&gt;-sSf&lt;/span&gt; https://thegreekman76.github.io/fitz/install.sh | sh

&lt;span class="c"&gt;# Windows (PowerShell)&lt;/span&gt;
irm https://thegreekman76.github.io/fitz/install.ps1 | iex

&lt;span class="c"&gt;# Reopen the terminal, then:&lt;/span&gt;
fitz new my-prod-api &lt;span class="nt"&gt;--http&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;my-prod-api
&lt;span class="c"&gt;# Edit main.fitz to add @healthz, @table, an HTTP route&lt;/span&gt;
fitz docker init
fitz deploy compose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For VSCode (recommended — hover with types, autocomplete, signature help): grab the &lt;code&gt;fitz-lang-&amp;lt;platform&amp;gt;.vsix&lt;/code&gt; from the &lt;a href="https://github.com/Thegreekman76/fitz/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt; and &lt;code&gt;code --install-extension fitz-lang-&amp;lt;platform&amp;gt;.vsix --force&lt;/code&gt;. The Language Server is bundled.&lt;/p&gt;

&lt;p&gt;You'll have a service with healthchecks, observability, and Docker compose in your project in under five minutes. Let me know what broke.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Docs and course&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Guide chapter on deployment&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz/guide/#35-deployment&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Roadmap&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/docs/roadmap.md" rel="noopener noreferrer"&gt;docs/roadmap.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CHANGELOG&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;CHANGELOG.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Issues&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/issues" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/issues&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Until the next one.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>opensource</category>
      <category>observability</category>
    </item>
    <item>
      <title>Construí tu primera API con Fitz: un acortador de URLs con Postgres y auth en 30 minutos</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Tue, 09 Jun 2026 10:12:08 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/construi-tu-primera-api-con-fitz-un-acortador-de-urls-con-postgres-y-auth-en-30-minutos-33ed</link>
      <guid>https://dev.to/martin_palopoli/construi-tu-primera-api-con-fitz-un-acortador-de-urls-con-postgres-y-auth-en-30-minutos-33ed</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Tutorial paso a paso por Fitz. Arrancamos desde &lt;code&gt;fitz new&lt;/code&gt;, terminamos con un binario nativo corriendo en Docker. Sin dependencias externas. Sin pip install. Solo Postgres tipado, auth con JWT, y OpenAPI auto-generado.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Qué vamos a construir
&lt;/h2&gt;

&lt;p&gt;Un acortador de URLs con las cuatro cosas que toda API real necesita:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Endpoints HTTP&lt;/strong&gt; para crear, redirigir, y ver stats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistencia en Postgres&lt;/strong&gt; para los links y el contador de clicks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autenticación con JWT&lt;/strong&gt; — solo los usuarios logueados pueden crear URLs cortas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un binario nativo&lt;/strong&gt; con &lt;code&gt;fitz build&lt;/code&gt;, listo para meter en un contenedor.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tamaño final: ~120 líneas de Fitz. Sin &lt;code&gt;requirements.txt&lt;/code&gt;. Sin &lt;code&gt;package.json&lt;/code&gt;. Sin &lt;code&gt;cargo add&lt;/code&gt;. Solo &lt;code&gt;fitz&lt;/code&gt; y Postgres.&lt;/p&gt;

&lt;p&gt;Vas a salir con:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;POST /login&lt;/code&gt; → cambiá credenciales por un JWT.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /shorten&lt;/code&gt; (requiere auth) → devuelve el código corto.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /{code}&lt;/code&gt; → redirige a la URL original e incrementa el contador.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /stats/{code}&lt;/code&gt; (requiere auth) → muestra clicks y fecha de creación.&lt;/li&gt;
&lt;li&gt;Un schema OpenAPI 3.1 auto-generado en &lt;code&gt;/openapi.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;La UI de Scalar en &lt;code&gt;/docs&lt;/code&gt; para probar desde el browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Vamos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup (2 minutos)
&lt;/h2&gt;

&lt;p&gt;Instalá Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Linux / macOS / WSL&lt;/span&gt;
curl &lt;span class="nt"&gt;-sSf&lt;/span&gt; https://thegreekman76.github.io/fitz/install.sh | sh

&lt;span class="c"&gt;# Windows (PowerShell)&lt;/span&gt;
irm https://thegreekman76.github.io/fitz/install.ps1 | iex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;O bajá un binario pre-compilado desde &lt;a href="https://github.com/Thegreekman76/fitz/releases" rel="noopener noreferrer"&gt;releases&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extensión VSCode&lt;/strong&gt; (fuertemente recomendada para este tutorial — te da hover con tipos, autocomplete, signature help, format on save): desde la misma &lt;a href="https://github.com/Thegreekman76/fitz/releases" rel="noopener noreferrer"&gt;página de releases&lt;/a&gt; bajá el &lt;code&gt;fitz-lang-&amp;lt;plataforma&amp;gt;.vsix&lt;/code&gt; que matchee tu OS e instalalo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;code &lt;span class="nt"&gt;--install-extension&lt;/span&gt; fitz-lang-&amp;lt;plataforma&amp;gt;.vsix &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El Language Server viene incluido adentro del &lt;code&gt;.vsix&lt;/code&gt; — no hace falta instalarlo aparte. Recargá VSCode una vez después del install.&lt;/p&gt;

&lt;p&gt;Reabrí la terminal para que el cambio de PATH aplique, después verificá:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# fitz 0.15.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;También vas a necesitar un Postgres corriendo. Lo más rápido es Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; pg-shortener &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;shortener &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 5432:5432 &lt;span class="se"&gt;\&lt;/span&gt;
  postgres:16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ahora creá el proyecto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz new url-shortener &lt;span class="nt"&gt;--http&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;url-shortener
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El flag &lt;code&gt;--http&lt;/code&gt; templatea un &lt;code&gt;main.fitz&lt;/code&gt; con &lt;code&gt;@get&lt;/code&gt; + &lt;code&gt;@server&lt;/code&gt; ya cableados. Abrí &lt;code&gt;main.fitz&lt;/code&gt; y arrancamos a editar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paso 1 — Hello world HTTP
&lt;/h2&gt;

&lt;p&gt;Reemplazá &lt;code&gt;main.fitz&lt;/code&gt; con este mínimo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(8080)
fn main() =&amp;gt; 0

@get("/health")
fn health() -&amp;gt; Str =&amp;gt; "ok"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Corrélo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fitz dev&lt;/code&gt; watchea el archivo y respawnea ante cada guardado — es el equivalente Fitz de &lt;code&gt;uvicorn --reload&lt;/code&gt;. En otra terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl localhost:8080/health
&lt;span class="c"&gt;# ok&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Abrí &lt;code&gt;http://localhost:8080/docs&lt;/code&gt; en el browser. Ya tenés una UI de Scalar con el &lt;code&gt;GET /health&lt;/code&gt; documentado. Sin magia de decoradores-que-registran-rutas, sin &lt;code&gt;app.include_router(...)&lt;/code&gt;. El compilador lee el AST y genera el schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paso 2 — Modelar el dominio
&lt;/h2&gt;

&lt;p&gt;Un &lt;code&gt;Link&lt;/code&gt; es el código corto, la URL original, el contador, y el dueño.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type Link {
    @primary id: Int,
    code: Str,
    target_url: Str,
    user_email: Str,
    clicks: Int,
    created_at: Str,
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El decorador &lt;code&gt;@primary&lt;/code&gt; lo marca como primary key. &lt;code&gt;Int&lt;/code&gt; es &lt;code&gt;i64&lt;/code&gt; en el binario generado, &lt;code&gt;bigint&lt;/code&gt; en Postgres. El ORM entiende esto porque el tipo se lee en el compilador — no en tiempo de ejecución.&lt;/p&gt;

&lt;p&gt;Pero todavía no le dijimos a Fitz que este &lt;code&gt;type&lt;/code&gt; es una tabla Postgres. Agregamos &lt;code&gt;@table&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@table("links")
type Link {
    @primary id: Int,
    code: Str,
    target_url: Str,
    user_email: Str,
    clicks: Int,
    created_at: Str,
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ahora &lt;code&gt;Link.all(db)&lt;/code&gt;, &lt;code&gt;Link.insert(db, l)&lt;/code&gt;, &lt;code&gt;Link.where(...)&lt;/code&gt;, &lt;code&gt;.preload(...)&lt;/code&gt; etc. existen sobre este tipo. El checker valida estáticamente que cualquier closure adentro de &lt;code&gt;.where(...)&lt;/code&gt; solo referencie campos que existen. Los typos mueren en compile time, no en producción.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paso 3 — Conectarse a Postgres
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;db.connect(url)&lt;/code&gt; abre un pool de conexiones. Lo hacemos una vez al boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let DB_URL = env_or("DATABASE_URL", "postgres://postgres:demo@localhost:5432/shortener")
let db = db.connect(DB_URL)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;env_or&lt;/code&gt; es un built-in: lee la variable de entorno, fallback al default si no está. Útil para dev local + Docker sin condicionales.&lt;/p&gt;

&lt;p&gt;También necesitamos que la tabla exista. La herramienta correcta acá son las &lt;strong&gt;migraciones de schema&lt;/strong&gt; — Fitz trae &lt;code&gt;fitz db diff&lt;/code&gt; y &lt;code&gt;fitz db migrate&lt;/code&gt; built-in. Declarás la tabla como un type &lt;code&gt;@table&lt;/code&gt;, y dejás que el migrator haga el SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@table("links")
type Link {
    @primary id: Int,
    code: Str,
    target_url: Str,
    user_email: Str,
    clicks: Int = 0,
    created_at: Str = "",
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La primera vez:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;fitz db diff
+ CREATE TABLE links &lt;span class="o"&gt;(&lt;/span&gt;
+     &lt;span class="nb"&gt;id &lt;/span&gt;BIGSERIAL PRIMARY KEY,
+     code TEXT NOT NULL,
+     target_url TEXT NOT NULL,
+     user_email TEXT NOT NULL,
+     clicks BIGINT NOT NULL DEFAULT 0,
+     created_at TEXT NOT NULL DEFAULT &lt;span class="s1"&gt;''&lt;/span&gt;
+ &lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;fitz db migrate
✓ aplicada migration_20260530_links.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fitz db diff&lt;/code&gt; lee los types &lt;code&gt;@table&lt;/code&gt; de tu código, introspecciona la DB viva, computa el SQL necesario para sincronizarlas, y emite un archivo de migración idempotente. &lt;code&gt;fitz db migrate&lt;/code&gt; aplica las migraciones no aplicadas en orden. Mismo modelo que Alembic, pero con los types como fuente de verdad en lugar de archivos SQL separados que tenés que mantener alineados a mano.&lt;/p&gt;

&lt;p&gt;Cuando después agregás &lt;code&gt;name: Str&lt;/code&gt; al &lt;code&gt;Link&lt;/code&gt;, &lt;code&gt;fitz db diff&lt;/code&gt; emite &lt;code&gt;ALTER TABLE links ADD COLUMN name TEXT NOT NULL&lt;/code&gt;. Re-ejecutás &lt;code&gt;fitz db migrate&lt;/code&gt;. Sin DDL escrito a mano.&lt;/p&gt;

&lt;p&gt;Para este tutorial, ejecutá &lt;code&gt;fitz db diff&lt;/code&gt; + &lt;code&gt;fitz db migrate&lt;/code&gt; una vez antes de levantar el server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paso 4 — Crear + redirigir
&lt;/h2&gt;

&lt;p&gt;Dos endpoints. Lo interesante es cuán compactos son:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type ShortenRequest { target_url: Str }
type ShortenResponse { code: Str, short_url: Str }

@post("/shorten")
async fn shorten(db: DbConn, req: ShortenRequest, user: User) -&amp;gt; ShortenResponse {
    let code = generar_codigo()
    let link = Link {
        id: 0,
        code: code,
        target_url: req.target_url,
        user_email: user.email,
        clicks: 0,
        created_at: "",  // el default de la DB lo completa
    }
    Link.insert(db, link).await
    return ShortenResponse {
        code: code,
        short_url: "http://localhost:8080/{code}",
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres cosas para notar:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;El parámetro &lt;code&gt;db: DbConn&lt;/code&gt;&lt;/strong&gt; — el runtime inyecta la conexión automáticamente. Fitz se da cuenta por el tipo que viene del pool global.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El parámetro &lt;code&gt;user: User&lt;/code&gt;&lt;/strong&gt; — viene de la auth, que cableamos en el próximo paso. El compilador valida estáticamente que el handler tenga &lt;code&gt;@authenticated&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;id: 0&lt;/code&gt;&lt;/strong&gt; — sentinel que le dice al ORM "es el primer insert, ignorá el campo, dejá que &lt;code&gt;BIGSERIAL&lt;/code&gt; lo asigne". El ORM omite el campo del &lt;code&gt;INSERT&lt;/code&gt; automáticamente.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;La redirección:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@get("/{code}")
async fn redirect(db: DbConn, code: Str) -&amp;gt; Result&amp;lt;HttpResponse&amp;gt; {
    let link: Link = match Link.where(fn(l) =&amp;gt; l.code == code).first(db).await {
        Ok(l) =&amp;gt; l,
        Err(_) =&amp;gt; return Err("no encontrado"),
    }
    // incrementar el contador sin bloquear la redirección
    spawn(incrementar_clicks(db, link.id))
    return Ok(redirect_to(link.target_url))
}

@background
async fn incrementar_clicks(db: DbConn, link_id: Int) {
    Link.where(fn(l) =&amp;gt; l.id == link_id)
        .update(db, { "clicks": "clicks + 1" })
        .await
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La closure &lt;code&gt;fn(l) =&amp;gt; l.code == code&lt;/code&gt; se traduce a SQL parametrizado en compile time: &lt;code&gt;WHERE code = $1&lt;/code&gt;. No hay eval, ni string concat, ni riesgo de SQL injection. La variable &lt;code&gt;code&lt;/code&gt; se convierte en un bind param.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;spawn(incrementar_clicks(...))&lt;/code&gt; es fire-and-forget — la respuesta sale sin esperar. El decorador &lt;code&gt;@background&lt;/code&gt; autoriza a la función a ser invocada desde un &lt;code&gt;spawn&lt;/code&gt;. Es un cerco-de-intención: nada se va a un scheduler en background por accidente.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paso 5 — Auth con JWT
&lt;/h2&gt;

&lt;p&gt;Tres piezas: el tipo &lt;code&gt;User&lt;/code&gt;, el &lt;code&gt;@auth_provider&lt;/code&gt;, y el endpoint de &lt;code&gt;/login&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type User { email: Str, name: Str }
type LoginRequest { email: Str, password: Str }
type LoginResponse { token: Str }

let JWT_SECRET = env_or("JWT_SECRET", "demo-secret-cambiame")
let DEMO_HASH = hash.password("demo123")

@auth_provider
fn check_token(headers: Map&amp;lt;Str, Str&amp;gt;) -&amp;gt; Result&amp;lt;User&amp;gt; {
    let auth: Str = match headers.get("authorization") {
        Ok(v) =&amp;gt; v,
        Err(_) =&amp;gt; return Err("falta Authorization"),
    }
    let parts = auth.split(" ")
    if (parts.len() != 2 or parts[0] != "Bearer") {
        return Err("se esperaba 'Bearer &amp;lt;token&amp;gt;'")
    }
    let claims = jwt.decode(parts[1], JWT_SECRET)?
    return Ok(User { email: claims["email"], name: claims["name"] })
}

@post("/login")
fn login(creds: LoginRequest) -&amp;gt; LoginResponse {
    if (creds.email != "ada@example.com") {
        return 401 { "error": "credenciales inválidas" }
    }
    if (not hash.verify(creds.password, DEMO_HASH)) {
        return 401 { "error": "credenciales inválidas" }
    }
    let claims = { "email": creds.email, "name": "Ada" }
    return LoginResponse { token: jwt.encode(claims, JWT_SECRET) }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Qué está pasando:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;hash.password(...)&lt;/code&gt; es &lt;strong&gt;Argon2id&lt;/strong&gt; (la recomendación de OWASP para hashing de passwords en 2026). Al boot del programa hasheamos el password demo &lt;code&gt;"demo123"&lt;/code&gt; — en producción esto sería una query SQL.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;jwt.encode(...)&lt;/code&gt; firma con HS256 por default. Los claims son un &lt;code&gt;Map&amp;lt;Str, Str&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@auth_provider&lt;/code&gt; es el &lt;strong&gt;singleton global&lt;/strong&gt; del programa. El checker exige un solo &lt;code&gt;@auth_provider&lt;/code&gt; por programa y que los handlers &lt;code&gt;@authenticated&lt;/code&gt; referencien un tipo &lt;code&gt;User&lt;/code&gt; válido.&lt;/li&gt;
&lt;li&gt;El operador &lt;code&gt;?&lt;/code&gt; sobre &lt;code&gt;jwt.decode(...)?&lt;/code&gt; propaga el error hacia arriba — token inválido, expirado, firma incorrecta — todo termina como &lt;code&gt;Err&lt;/code&gt; que el runtime de auth mapea a 401.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ahora para que los handlers requieran auth, apilamos el decorador:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@authenticated
@post("/shorten")
async fn shorten(db: DbConn, req: ShortenRequest, user: User) -&amp;gt; ShortenResponse { ... }

@authenticated
@get("/stats/{code}")
async fn stats(db: DbConn, code: Str, user: User) -&amp;gt; Result&amp;lt;Link&amp;gt; {
    return Link.where(fn(l) =&amp;gt; l.code == code and l.user_email == user.email)
               .first(db)
               .await
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El parámetro &lt;code&gt;user: User&lt;/code&gt; lo &lt;strong&gt;inyecta automáticamente&lt;/strong&gt; el runtime después de que el provider valida el token. Si el token falta o es inválido → 401 sin que el handler corra siquiera. El schema OpenAPI refleja todo esto: aparece el &lt;code&gt;securitySchemes.bearerAuth&lt;/code&gt;, los handlers protegidos llevan &lt;code&gt;security: [{bearerAuth: []}]&lt;/code&gt;, y el 401 queda documentado automático.&lt;/p&gt;

&lt;p&gt;El decorador &lt;code&gt;@admin&lt;/code&gt; (no lo usamos acá) va más allá: además del token exige &lt;code&gt;user.role == "admin"&lt;/code&gt;. El compilador valida estáticamente que &lt;code&gt;User&lt;/code&gt; tenga un campo &lt;code&gt;role: Str&lt;/code&gt;. Si no lo tiene, error en compile time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paso 6 — Probarlo
&lt;/h2&gt;

&lt;p&gt;Con &lt;code&gt;fitz dev&lt;/code&gt; corriendo en otra terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Login&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:8080/login &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"ada@example.com","password":"demo123"}'&lt;/span&gt;
&lt;span class="c"&gt;# {"token":"eyJ0eXAiOiJKV1Qi..."}&lt;/span&gt;

&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"eyJ0eXAi..."&lt;/span&gt;  &lt;span class="c"&gt;# pegá el token del response&lt;/span&gt;

&lt;span class="c"&gt;# Crear una URL corta&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:8080/shorten &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"target_url":"https://github.com/Thegreekman76/fitz"}'&lt;/span&gt;
&lt;span class="c"&gt;# {"code":"abc123","short_url":"http://localhost:8080/abc123"}&lt;/span&gt;

&lt;span class="c"&gt;# Redirección (el browser sigue; con curl usá -I)&lt;/span&gt;
curl &lt;span class="nt"&gt;-I&lt;/span&gt; localhost:8080/abc123
&lt;span class="c"&gt;# HTTP/1.1 302 Found&lt;/span&gt;
&lt;span class="c"&gt;# location: https://github.com/Thegreekman76/fitz&lt;/span&gt;

&lt;span class="c"&gt;# Stats&lt;/span&gt;
curl localhost:8080/stats/abc123 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# {"id":1,"code":"abc123","target_url":"...","clicks":1,"created_at":"..."}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Abrí &lt;code&gt;http://localhost:8080/docs&lt;/code&gt; y vas a ver la UI completa de Scalar: los cuatro endpoints con el schema correcto, el botón &lt;code&gt;Authorize&lt;/code&gt; para pegar el token que funciona, los endpoints protegidos con el iconito de candado. Nada de esto se configuró a mano — vino del compilador leyendo el AST.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paso 7 — Compilar a binario nativo
&lt;/h2&gt;

&lt;p&gt;Hasta ahora corrimos con &lt;code&gt;fitz run&lt;/code&gt; (interpretado). Ahora envíamos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eso produce &lt;code&gt;url-shortener&lt;/code&gt; (o &lt;code&gt;url-shortener.exe&lt;/code&gt; en Windows) — un binario nativo standalone. Todo lo demás está estáticamente linkeado excepto libc.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt; url-shortener
&lt;span class="c"&gt;# -rwxr-xr-x  1  user  user   18M  May 29 14:00 url-shortener&lt;/span&gt;

&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres://postgres:demo@localhost:5432/shortener ./url-shortener
&lt;span class="c"&gt;# server listening on 127.0.0.1:8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El requisito duro de Fitz es que &lt;code&gt;fitz run&lt;/code&gt; y &lt;code&gt;fitz build&lt;/code&gt; produzcan comportamiento &lt;strong&gt;bit-a-bit idéntico&lt;/strong&gt;. Mismo output JSON, mismos status codes, mismas queries SQL. Si divergen, es un bug.&lt;/p&gt;

&lt;p&gt;Para Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; gcr.io/distroless/cc-debian12&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; url-shortener /url-shortener&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; DATABASE_URL=postgres://...&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; JWT_SECRET=...&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["/url-shortener"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Imagen distroless con el binario adentro. ~30 MB final. Sin &lt;code&gt;python&lt;/code&gt;, sin &lt;code&gt;node&lt;/code&gt;, sin &lt;code&gt;cargo&lt;/code&gt; — solo tu binario compilado y un libc mínimo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paso 8 — Deploy con un solo comando
&lt;/h2&gt;

&lt;p&gt;En realidad no tenés que escribir ese Dockerfile a mano. Fitz lee el shape de tu programa y lo genera por vos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz docker init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esto crea tres archivos en tu proyecto:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Dockerfile&lt;/code&gt; — multi-stage, imagen builder más una stage de runtime con &lt;code&gt;gcr.io/distroless/cc-debian12&lt;/code&gt;. &lt;code&gt;EXPOSE 8080&lt;/code&gt; porque hay un &lt;code&gt;@server(8080)&lt;/code&gt; en el código.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.dockerignore&lt;/code&gt; — defaults razonables (&lt;code&gt;target/&lt;/code&gt;, &lt;code&gt;.git/&lt;/code&gt;, &lt;code&gt;.env*&lt;/code&gt;, &lt;code&gt;__pycache__/&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker-compose.yml&lt;/code&gt; — tu app &lt;strong&gt;más&lt;/strong&gt; un service &lt;code&gt;postgres:16-alpine&lt;/code&gt; con healthcheck y volumen &lt;code&gt;pgdata&lt;/code&gt;, porque hay un &lt;code&gt;db.connect(...)&lt;/code&gt; en el código. El env var &lt;code&gt;DATABASE_URL&lt;/code&gt; se cablea automático.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La detección es &lt;strong&gt;AST-only&lt;/strong&gt; (~50ms). No hace falta ejecutar el programa para saber qué infraestructura querés. Si hubieras tenido &lt;code&gt;@cron&lt;/code&gt;, hubiera agregado &lt;code&gt;restart: unless-stopped&lt;/code&gt;. Si hubieras tenido &lt;code&gt;from python import ...&lt;/code&gt;, hubiera elegido &lt;code&gt;python:3.12-slim-bookworm&lt;/code&gt; en lugar de distroless. &lt;strong&gt;Genera lo que vos escribirías a mano&lt;/strong&gt;, lo commiteás, lo editás cuando lo necesites.&lt;/p&gt;

&lt;p&gt;Ahora shippeás:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Build de imagen, push al registry.&lt;/span&gt;
fitz deploy docker &lt;span class="nt"&gt;--tag&lt;/span&gt; mycorp/shortener:v1

&lt;span class="c"&gt;# O levantá local con compose (útil para QA local).&lt;/span&gt;
fitz deploy compose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fitz deploy&lt;/code&gt; es un wrapper fino sobre los CLIs nativos &lt;code&gt;docker&lt;/code&gt;/&lt;code&gt;docker compose&lt;/code&gt;. No se trata de inventar herramientas nuevas — se trata de que dejes de perseguir errores de Dockerfile equivocados durante dos días cada vez que arrancás un proyecto. Los correctos ya están ahí.&lt;/p&gt;

&lt;p&gt;Si querés deploy del binario sin Docker, podés seguir haciendo lo de paso 7 — &lt;code&gt;scp&lt;/code&gt; del binario, correrlo. El output de &lt;code&gt;fitz build&lt;/code&gt; es genuinamente standalone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Detalles production: healthchecks, secrets, observability
&lt;/h3&gt;

&lt;p&gt;Ya que estamos hablando de producción, Fitz ya tiene el resto del checklist como parte del lenguaje:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@healthz
fn liveness() -&amp;gt; Bool =&amp;gt; true

@readyz
async fn readiness(db: DbConn) -&amp;gt; Bool {
    return match db.exec("SELECT 1").await {
        Ok(_) =&amp;gt; true,
        Err(_) =&amp;gt; false,
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/healthz&lt;/code&gt; y &lt;code&gt;/readyz&lt;/code&gt; se auto-montan en el router HTTP. Kubernetes contento.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let JWT_SECRET: Secret&amp;lt;Str&amp;gt; = secret("JWT_SECRET")  // nunca printea, nunca loguea el valor
let LOG_LEVEL: Str = config("LOG_LEVEL", "info")    // env var tipada con default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; es un type opaco — &lt;code&gt;print(JWT_SECRET)&lt;/code&gt; imprime &lt;code&gt;"***"&lt;/code&gt;, la serialización JSON lo redacta, las llamadas a &lt;code&gt;log.info(...)&lt;/code&gt; lo strippean de los fields estructurados. La única forma de exponer el valor es &lt;code&gt;.expose()&lt;/code&gt; — explícito y greppable en code review.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Observability con un env var, cero cambios de código.&lt;/span&gt;
&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://jaeger:4318 ./url-shortener
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cuando el env var está seteado, cada request HTTP abre un span que exporta a OpenTelemetry. &lt;code&gt;trace_id&lt;/code&gt;/&lt;code&gt;span_id&lt;/code&gt; se propagan a cada &lt;code&gt;log.info(...)&lt;/code&gt; adentro del handler — grepeás Jaeger por &lt;code&gt;trace_id&lt;/code&gt; y encontrás cada log line relacionado al instante. Cuando el env var &lt;strong&gt;no&lt;/strong&gt; está seteado, hay cero overhead de red — el exporter es no-op.&lt;/p&gt;

&lt;p&gt;No tenés que enchufar nada de esto. Ya está.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Cuán rápido es lo que acabás de construir?
&lt;/h3&gt;

&lt;p&gt;Aprovechando que hablamos de producción: el boilerplate &lt;code&gt;api-postgres-python&lt;/code&gt; del repo implementa &lt;strong&gt;el mismo CRUD con forma de shortener&lt;/strong&gt; que vos escribiste en este tutorial, pero con FastAPI + SQLAlchemy + asyncpg. Mismo Postgres, mismo schema, mismos endpoints, mismo shape de JSON, mismo &lt;code&gt;docker compose&lt;/code&gt;. Desde el bench reproducible en &lt;strong&gt;v0.10.13&lt;/strong&gt; (Intel Core Ultra 7 155H, Docker 29.2.1, 30s sostenidos, concurrencia 10):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Métrica&lt;/th&gt;
&lt;th&gt;Fitz ORM&lt;/th&gt;
&lt;th&gt;Python + SQLAlchemy&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Memory peak&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.2 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;51 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.5× más eficiente&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /users&lt;/code&gt; p50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.88 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;37.85 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.76×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /users&lt;/code&gt; RPS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1944&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;246&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.91×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /users/{id}&lt;/code&gt; p50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.60 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;31.87 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.85×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /users/{id}&lt;/code&gt; RPS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2604&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;296&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.80×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold start&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.14 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.22 s&lt;/td&gt;
&lt;td&gt;1.57×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image size&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;131 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;258 MB&lt;/td&gt;
&lt;td&gt;2× más liviano&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Misma máquina, misma red Docker, mismo Postgres. El binario que acabás de compilar en el paso 7 está en la columna de la izquierda. Corré &lt;code&gt;bash benchmarks/orm-vs-sqlalchemy/run.sh&lt;/code&gt; desde el repo para reproducir en tu hardware (~5–8 min con cache Docker caliente; requiere &lt;code&gt;oha&lt;/code&gt; + &lt;code&gt;jq&lt;/code&gt;). Metodología completa y output crudo en &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/benchmarks/orm-vs-sqlalchemy/README.md" rel="noopener noreferrer"&gt;&lt;code&gt;benchmarks/orm-vs-sqlalchemy/README.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que te queda
&lt;/h2&gt;

&lt;p&gt;Un acortador de URLs funcionando en ~120 líneas de Fitz:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server HTTP con OpenAPI 3.1 + UI Scalar.&lt;/li&gt;
&lt;li&gt;ORM Postgres con closure-to-SQL tipado y &lt;strong&gt;&lt;code&gt;fitz db diff&lt;/code&gt;/&lt;code&gt;migrate&lt;/code&gt;&lt;/strong&gt; para schema.&lt;/li&gt;
&lt;li&gt;Auth con JWT + passwords Argon2id.&lt;/li&gt;
&lt;li&gt;Jobs en background con &lt;code&gt;spawn&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Binario nativo construido con &lt;code&gt;fitz build&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dockerfile + compose generados&lt;/strong&gt; del AST con &lt;code&gt;fitz docker init&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz deploy&lt;/code&gt;&lt;/strong&gt; wrappeando &lt;code&gt;docker build/push&lt;/code&gt; y &lt;code&gt;docker compose up&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Health checks vía &lt;code&gt;@healthz&lt;/code&gt;/&lt;code&gt;@readyz&lt;/code&gt;, secrets vía &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt;, observability vía OpenTelemetry — todo built-in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lo que &lt;strong&gt;no&lt;/strong&gt; instalaste:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;FastAPI, Uvicorn, Pydantic.&lt;/li&gt;
&lt;li&gt;SQLAlchemy, asyncpg, &lt;strong&gt;alembic&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;python-jose, passlib, argon2-cffi.&lt;/li&gt;
&lt;li&gt;Celery, Redis (para el spawn).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OpenTelemetry SDK + paquetes de auto-instrumentation.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Un linter de Dockerfile, un generador de compose, un script de deploy.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dependencias externas totales de tu proyecto: &lt;strong&gt;0&lt;/strong&gt;. Solo Fitz y Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué falta (para contexto)
&lt;/h2&gt;

&lt;p&gt;En un shortener real igualmente querrías:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Usuarios reales&lt;/strong&gt; — hardcodeamos a Ada. Una tabla &lt;code&gt;User&lt;/code&gt; real con &lt;code&gt;@table&lt;/code&gt; son dos minutos más.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting por usuario&lt;/strong&gt; — middleware encima del &lt;code&gt;@authenticated&lt;/code&gt;. Un &lt;code&gt;@middleware(rate_limit)&lt;/code&gt; más un contador chico en Redis/Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytics custom&lt;/strong&gt; — más allá del contador de clicks, querrías user agent, referer, etc. Se suma en 5 líneas más.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tracking en background con retry&lt;/strong&gt; — &lt;code&gt;spawn&lt;/code&gt; es fire-and-forget. Para retries reales, los jobs &lt;code&gt;@cron&lt;/code&gt; con persistencia + retry config (&lt;code&gt;@cron("expr", retry={max: 5, backoff: "exponential"}, store=db)&lt;/code&gt;) ya están soportados — cableás una tabla &lt;code&gt;clicks_queue&lt;/code&gt; y un &lt;code&gt;@cron&lt;/code&gt; que la drene.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Todo realizable hoy con lo que está en la caja.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué viene en la serie
&lt;/h2&gt;

&lt;p&gt;En el próximo post &lt;strong&gt;sumamos WebSockets&lt;/strong&gt; al shortener: un dashboard en tiempo real que mira los clicks en vivo, con la misma auth, con AsyncAPI generado automático.&lt;/p&gt;

&lt;p&gt;Si te trabaste en algún paso o construiste algo interesante, escribí en &lt;a href="https://github.com/Thegreekman76/fitz/issues" rel="noopener noreferrer"&gt;issues de GitHub&lt;/a&gt; — leo todos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo final&lt;/strong&gt;: el código completo de este tutorial está en &lt;a href="https://github.com/Thegreekman76/fitz/tree/main/boilerplates/api-orm-full" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/tree/main/boilerplates/api-orm-full&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docs y curso&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Guía (34 capítulos)&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz/guide/&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Roadmap&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/docs/roadmap.md" rel="noopener noreferrer"&gt;docs/roadmap.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CHANGELOG&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;CHANGELOG.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nos vemos en el próximo.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>postgres</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Build a URL shortener with Fitz: HTTP + Postgres + auth in 30 minutes</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Tue, 09 Jun 2026 10:11:49 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/build-a-url-shortener-with-fitz-http-postgres-auth-in-30-minutes-68k</link>
      <guid>https://dev.to/martin_palopoli/build-a-url-shortener-with-fitz-http-postgres-auth-in-30-minutes-68k</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A step-by-step tutorial through Fitz. We start from &lt;code&gt;fitz new&lt;/code&gt;, finish with a native binary running in Docker. No external dependencies. No pip install. Just typed Postgres, JWT auth, and OpenAPI auto-generated.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;A URL shortener with the four things every real API needs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;HTTP endpoints&lt;/strong&gt; for create, redirect, and stats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres persistence&lt;/strong&gt; for the links and the click counter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWT authentication&lt;/strong&gt; — only logged-in users can create short URLs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A native binary&lt;/strong&gt; with &lt;code&gt;fitz build&lt;/code&gt;, ready to drop into a container.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Final size: ~120 lines of Fitz. No &lt;code&gt;requirements.txt&lt;/code&gt;. No &lt;code&gt;package.json&lt;/code&gt;. No &lt;code&gt;cargo add&lt;/code&gt;. Just &lt;code&gt;fitz&lt;/code&gt; and Postgres.&lt;/p&gt;

&lt;p&gt;You'll come out with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;POST /login&lt;/code&gt; → swap credentials for a JWT.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /shorten&lt;/code&gt; (auth required) → returns the short code.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /{code}&lt;/code&gt; → redirects to the original URL and increments the counter.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /stats/{code}&lt;/code&gt; (auth required) → shows clicks and creation date.&lt;/li&gt;
&lt;li&gt;An auto-generated OpenAPI 3.1 schema at &lt;code&gt;/openapi.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The Scalar UI at &lt;code&gt;/docs&lt;/code&gt; to test from the browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup (2 minutes)
&lt;/h2&gt;

&lt;p&gt;Install Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Linux / macOS / WSL&lt;/span&gt;
curl &lt;span class="nt"&gt;-sSf&lt;/span&gt; https://thegreekman76.github.io/fitz/install.sh | sh

&lt;span class="c"&gt;# Windows (PowerShell)&lt;/span&gt;
irm https://thegreekman76.github.io/fitz/install.ps1 | iex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or grab a pre-compiled binary from the &lt;a href="https://github.com/Thegreekman76/fitz/releases" rel="noopener noreferrer"&gt;releases&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VSCode extension&lt;/strong&gt; (strongly recommended for this tutorial — you get hover with types, autocomplete, signature help, format on save): from the same &lt;a href="https://github.com/Thegreekman76/fitz/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt; grab the &lt;code&gt;fitz-lang-&amp;lt;platform&amp;gt;.vsix&lt;/code&gt; matching your OS and install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;code &lt;span class="nt"&gt;--install-extension&lt;/span&gt; fitz-lang-&amp;lt;platform&amp;gt;.vsix &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Language Server is bundled inside the &lt;code&gt;.vsix&lt;/code&gt; — no separate install. Reload VSCode once after the install.&lt;/p&gt;

&lt;p&gt;Reopen the terminal so the PATH change applies, then check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# fitz 0.15.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll also need a running Postgres. The fastest is Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; pg-shortener &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;shortener &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 5432:5432 &lt;span class="se"&gt;\&lt;/span&gt;
  postgres:16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now create the project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz new url-shortener &lt;span class="nt"&gt;--http&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;url-shortener
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--http&lt;/code&gt; flag templates a &lt;code&gt;main.fitz&lt;/code&gt; with &lt;code&gt;@get&lt;/code&gt; + &lt;code&gt;@server&lt;/code&gt; already wired. Open &lt;code&gt;main.fitz&lt;/code&gt; and let's start editing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Hello world HTTP
&lt;/h2&gt;

&lt;p&gt;Replace &lt;code&gt;main.fitz&lt;/code&gt; with this minimum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(8080)
fn main() =&amp;gt; 0

@get("/health")
fn health() -&amp;gt; Str =&amp;gt; "ok"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fitz dev&lt;/code&gt; watches the file and respawns on every save — it's the Fitz equivalent of &lt;code&gt;uvicorn --reload&lt;/code&gt;. In another terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl localhost:8080/health
&lt;span class="c"&gt;# ok&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:8080/docs&lt;/code&gt; in your browser. You already have a Scalar UI with &lt;code&gt;GET /health&lt;/code&gt; documented. No decorator-to-register-routes magic, no &lt;code&gt;app.include_router(...)&lt;/code&gt;. The compiler reads the AST and generates the schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Model the domain
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;Link&lt;/code&gt; is the short code, the original URL, the counter, and the owner.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type Link {
    @primary id: Int,
    code: Str,
    target_url: Str,
    user_email: Str,
    clicks: Int,
    created_at: Str,
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@primary&lt;/code&gt; decorator marks it as primary key. &lt;code&gt;Int&lt;/code&gt; is &lt;code&gt;i64&lt;/code&gt; in the generated binary, &lt;code&gt;bigint&lt;/code&gt; in Postgres. The ORM understands this because the type is read in the compiler — not at runtime.&lt;/p&gt;

&lt;p&gt;But we haven't told Fitz that this &lt;code&gt;type&lt;/code&gt; is a Postgres table. Adding &lt;code&gt;@table&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@table("links")
type Link {
    @primary id: Int,
    code: Str,
    target_url: Str,
    user_email: Str,
    clicks: Int,
    created_at: Str,
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;Link.all(db)&lt;/code&gt;, &lt;code&gt;Link.insert(db, l)&lt;/code&gt;, &lt;code&gt;Link.where(...)&lt;/code&gt;, &lt;code&gt;.preload(...)&lt;/code&gt; etc. exist on this type. The checker validates statically that any closure inside &lt;code&gt;.where(...)&lt;/code&gt; only references existing fields. Typos die at compile time, not in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Connect to Postgres
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;db.connect(url)&lt;/code&gt; opens a connection pool. We do it once at boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let DB_URL = env_or("DATABASE_URL", "postgres://postgres:demo@localhost:5432/shortener")
let db = db.connect(DB_URL)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;env_or&lt;/code&gt; is a built-in: reads the environment variable, falls back to the default if it's not set. Useful for local dev + Docker without conditionals.&lt;/p&gt;

&lt;p&gt;We also need the table to exist. The right tool here is &lt;strong&gt;schema migrations&lt;/strong&gt; — Fitz has &lt;code&gt;fitz db diff&lt;/code&gt; and &lt;code&gt;fitz db migrate&lt;/code&gt; built-in. Declare the table as a &lt;code&gt;@table&lt;/code&gt; type, then let the migrator do the SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@table("links")
type Link {
    @primary id: Int,
    code: Str,
    target_url: Str,
    user_email: Str,
    clicks: Int = 0,
    created_at: Str = "",
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;fitz db diff
+ CREATE TABLE links &lt;span class="o"&gt;(&lt;/span&gt;
+     &lt;span class="nb"&gt;id &lt;/span&gt;BIGSERIAL PRIMARY KEY,
+     code TEXT NOT NULL,
+     target_url TEXT NOT NULL,
+     user_email TEXT NOT NULL,
+     clicks BIGINT NOT NULL DEFAULT 0,
+     created_at TEXT NOT NULL DEFAULT &lt;span class="s1"&gt;''&lt;/span&gt;
+ &lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;fitz db migrate
✓ applied migration_20260530_links.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fitz db diff&lt;/code&gt; reads the &lt;code&gt;@table&lt;/code&gt; types in your code, introspects the live DB, computes the SQL needed to bring them in sync, and emits an idempotent migration file. &lt;code&gt;fitz db migrate&lt;/code&gt; applies the unapplied migrations in order. Same model as Alembic, but with the types as source of truth instead of separate SQL files you have to keep aligned by hand.&lt;/p&gt;

&lt;p&gt;When you add &lt;code&gt;name: Str&lt;/code&gt; to &lt;code&gt;Link&lt;/code&gt; later, &lt;code&gt;fitz db diff&lt;/code&gt; emits &lt;code&gt;ALTER TABLE links ADD COLUMN name TEXT NOT NULL&lt;/code&gt;. You re-run &lt;code&gt;fitz db migrate&lt;/code&gt;. No hand-written DDL.&lt;/p&gt;

&lt;p&gt;For this tutorial, run &lt;code&gt;fitz db diff&lt;/code&gt; + &lt;code&gt;fitz db migrate&lt;/code&gt; once before starting the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Create + redirect
&lt;/h2&gt;

&lt;p&gt;Two endpoints. The interesting thing is how compact they are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type ShortenRequest { target_url: Str }
type ShortenResponse { code: Str, short_url: Str }

@post("/shorten")
async fn shorten(db: DbConn, req: ShortenRequest, user: User) -&amp;gt; ShortenResponse {
    let code = generate_code()
    let link = Link {
        id: 0,
        code: code,
        target_url: req.target_url,
        user_email: user.email,
        clicks: 0,
        created_at: "",  // DB default fills it in
    }
    Link.insert(db, link).await
    return ShortenResponse {
        code: code,
        short_url: "http://localhost:8080/{code}",
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to note:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;db: DbConn&lt;/code&gt; parameter&lt;/strong&gt; — the runtime injects the connection automatically. Fitz figures out it's coming from the global pool by type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;user: User&lt;/code&gt; parameter&lt;/strong&gt; — this comes from auth, which we'll wire up in the next step. The compiler statically validates the handler is &lt;code&gt;@authenticated&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;id: 0&lt;/code&gt;&lt;/strong&gt; — sentinel that tells the ORM "this is the first insert, ignore the field, let &lt;code&gt;BIGSERIAL&lt;/code&gt; assign it". The ORM omits the field from the &lt;code&gt;INSERT&lt;/code&gt; automatically.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The redirect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@get("/{code}")
async fn redirect(db: DbConn, code: Str) -&amp;gt; Result&amp;lt;HttpResponse&amp;gt; {
    let link: Link = match Link.where(fn(l) =&amp;gt; l.code == code).first(db).await {
        Ok(l) =&amp;gt; l,
        Err(_) =&amp;gt; return Err("not found"),
    }
    // increment the counter without blocking the redirect
    spawn(increment_clicks(db, link.id))
    return Ok(redirect_to(link.target_url))
}

@background
async fn increment_clicks(db: DbConn, link_id: Int) {
    Link.where(fn(l) =&amp;gt; l.id == link_id)
        .update(db, { "clicks": "clicks + 1" })
        .await
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The closure &lt;code&gt;fn(l) =&amp;gt; l.code == code&lt;/code&gt; gets translated to parametrized SQL at compile time: &lt;code&gt;WHERE code = $1&lt;/code&gt;. There's no eval, no string concat, no SQL injection risk. The &lt;code&gt;code&lt;/code&gt; variable becomes a bind parameter.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;spawn(increment_clicks(...))&lt;/code&gt; is fire-and-forget — the response goes back without waiting. The &lt;code&gt;@background&lt;/code&gt; decorator authorizes the function to be called from a &lt;code&gt;spawn&lt;/code&gt;. It's a fence-of-intent: nothing accidentally goes to a background scheduler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — JWT auth
&lt;/h2&gt;

&lt;p&gt;Three pieces: the &lt;code&gt;User&lt;/code&gt; type, the &lt;code&gt;@auth_provider&lt;/code&gt;, the &lt;code&gt;/login&lt;/code&gt; endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type User { email: Str, name: Str }
type LoginRequest { email: Str, password: Str }
type LoginResponse { token: Str }

let JWT_SECRET = env_or("JWT_SECRET", "demo-secret-change-me")
let DEMO_HASH = hash.password("demo123")

@auth_provider
fn check_token(headers: Map&amp;lt;Str, Str&amp;gt;) -&amp;gt; Result&amp;lt;User&amp;gt; {
    let auth: Str = match headers.get("authorization") {
        Ok(v) =&amp;gt; v,
        Err(_) =&amp;gt; return Err("missing Authorization"),
    }
    let parts = auth.split(" ")
    if (parts.len() != 2 or parts[0] != "Bearer") {
        return Err("expected 'Bearer &amp;lt;token&amp;gt;'")
    }
    let claims = jwt.decode(parts[1], JWT_SECRET)?
    return Ok(User { email: claims["email"], name: claims["name"] })
}

@post("/login")
fn login(creds: LoginRequest) -&amp;gt; LoginResponse {
    if (creds.email != "ada@example.com") {
        return 401 { "error": "invalid credentials" }
    }
    if (not hash.verify(creds.password, DEMO_HASH)) {
        return 401 { "error": "invalid credentials" }
    }
    let claims = { "email": creds.email, "name": "Ada" }
    return LoginResponse { token: jwt.encode(claims, JWT_SECRET) }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What's happening:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;hash.password(...)&lt;/code&gt; is &lt;strong&gt;Argon2id&lt;/strong&gt; (the OWASP recommendation for password hashing in 2026). At program boot we hash the demo password &lt;code&gt;"demo123"&lt;/code&gt; — in production this would be a SQL query.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;jwt.encode(...)&lt;/code&gt; signs with HS256 by default. The claims are a &lt;code&gt;Map&amp;lt;Str, Str&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@auth_provider&lt;/code&gt; is the &lt;strong&gt;global singleton&lt;/strong&gt; of the program. The checker enforces only one &lt;code&gt;@auth_provider&lt;/code&gt; per program and that handlers &lt;code&gt;@authenticated&lt;/code&gt; reference a valid &lt;code&gt;User&lt;/code&gt; type.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;?&lt;/code&gt; operator on &lt;code&gt;jwt.decode(...)?&lt;/code&gt; propagates the error upward — invalid token, expired, wrong signature — everything ends up as &lt;code&gt;Err&lt;/code&gt; that the auth runtime maps to a 401.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now to require auth on the handlers, stack the decorator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@authenticated
@post("/shorten")
async fn shorten(db: DbConn, req: ShortenRequest, user: User) -&amp;gt; ShortenResponse { ... }

@authenticated
@get("/stats/{code}")
async fn stats(db: DbConn, code: Str, user: User) -&amp;gt; Result&amp;lt;Link&amp;gt; {
    return Link.where(fn(l) =&amp;gt; l.code == code and l.user_email == user.email)
               .first(db)
               .await
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;user: User&lt;/code&gt; parameter is &lt;strong&gt;automatically injected&lt;/strong&gt; by the runtime after the provider validates the token. If the token is missing/invalid → 401 without the handler ever running. The OpenAPI schema reflects all of this: &lt;code&gt;securitySchemes.bearerAuth&lt;/code&gt; appears, the protected handlers carry &lt;code&gt;security: [{bearerAuth: []}]&lt;/code&gt;, and 401 is documented automatically.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;@admin&lt;/code&gt; decorator (not used here) takes it further: it requires &lt;code&gt;user.role == "admin"&lt;/code&gt; in addition to the token. The compiler enforces statically that &lt;code&gt;User&lt;/code&gt; has a &lt;code&gt;role: Str&lt;/code&gt; field. If it doesn't, error at compile time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6 — Test it
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;fitz dev&lt;/code&gt; running on another terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Login&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:8080/login &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"ada@example.com","password":"demo123"}'&lt;/span&gt;
&lt;span class="c"&gt;# {"token":"eyJ0eXAiOiJKV1Qi..."}&lt;/span&gt;

&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"eyJ0eXAi..."&lt;/span&gt;  &lt;span class="c"&gt;# paste the token from the response&lt;/span&gt;

&lt;span class="c"&gt;# Create a short URL&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:8080/shorten &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"target_url":"https://github.com/Thegreekman76/fitz"}'&lt;/span&gt;
&lt;span class="c"&gt;# {"code":"abc123","short_url":"http://localhost:8080/abc123"}&lt;/span&gt;

&lt;span class="c"&gt;# Redirect (browser follows; with curl use -I)&lt;/span&gt;
curl &lt;span class="nt"&gt;-I&lt;/span&gt; localhost:8080/abc123
&lt;span class="c"&gt;# HTTP/1.1 302 Found&lt;/span&gt;
&lt;span class="c"&gt;# location: https://github.com/Thegreekman76/fitz&lt;/span&gt;

&lt;span class="c"&gt;# Stats&lt;/span&gt;
curl localhost:8080/stats/abc123 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# {"id":1,"code":"abc123","target_url":"...","clicks":1,"created_at":"..."}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:8080/docs&lt;/code&gt; and you'll see the full Scalar UI: all four endpoints with the right schema, the &lt;code&gt;Authorize&lt;/code&gt; button that paste the token works, the protected endpoints with the lock icon. None of this was set up by hand — it came from the compiler reading the AST.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7 — Compile to a native binary
&lt;/h2&gt;

&lt;p&gt;So far we ran with &lt;code&gt;fitz run&lt;/code&gt; (interpreted). Now we ship:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That produces &lt;code&gt;url-shortener&lt;/code&gt; (or &lt;code&gt;url-shortener.exe&lt;/code&gt; on Windows) — a single self-contained native binary. Everything is statically linked except libc.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt; url-shortener
&lt;span class="c"&gt;# -rwxr-xr-x  1  user  user   18M  May 29 14:00 url-shortener&lt;/span&gt;

&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres://postgres:demo@localhost:5432/shortener ./url-shortener
&lt;span class="c"&gt;# server listening on 127.0.0.1:8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hard requirement of Fitz is that &lt;code&gt;fitz run&lt;/code&gt; and &lt;code&gt;fitz build&lt;/code&gt; produce &lt;strong&gt;bit-for-bit identical&lt;/strong&gt; behavior. Same JSON output, same status codes, same SQL queries. If they diverge, it's a bug.&lt;/p&gt;

&lt;p&gt;For Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; gcr.io/distroless/cc-debian12&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; url-shortener /url-shortener&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; DATABASE_URL=postgres://...&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; JWT_SECRET=...&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["/url-shortener"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Distroless image with the binary inside. ~30 MB final. No &lt;code&gt;python&lt;/code&gt;, no &lt;code&gt;node&lt;/code&gt;, no &lt;code&gt;cargo&lt;/code&gt; — just your compiled binary and a minimal libc.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8 — Deploy with one command
&lt;/h2&gt;

&lt;p&gt;You don't actually have to write that Dockerfile by hand. Fitz reads the shape of your program and generates it for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz docker init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates three files in your project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Dockerfile&lt;/code&gt; — multi-stage, builder image plus a &lt;code&gt;gcr.io/distroless/cc-debian12&lt;/code&gt; runtime stage. &lt;code&gt;EXPOSE 8080&lt;/code&gt; because there's an &lt;code&gt;@server(8080)&lt;/code&gt; in the code.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.dockerignore&lt;/code&gt; — sane defaults (&lt;code&gt;target/&lt;/code&gt;, &lt;code&gt;.git/&lt;/code&gt;, &lt;code&gt;.env*&lt;/code&gt;, &lt;code&gt;__pycache__/&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker-compose.yml&lt;/code&gt; — your app &lt;strong&gt;plus&lt;/strong&gt; a &lt;code&gt;postgres:16-alpine&lt;/code&gt; service with a healthcheck and &lt;code&gt;pgdata&lt;/code&gt; volume, because there's a &lt;code&gt;db.connect(...)&lt;/code&gt; in the code. The &lt;code&gt;DATABASE_URL&lt;/code&gt; env var is wired automatically.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The detection is &lt;strong&gt;AST-only&lt;/strong&gt; (~50ms). No need to run the program to figure out what infrastructure it wants. If you had &lt;code&gt;@cron&lt;/code&gt;, it would add &lt;code&gt;restart: unless-stopped&lt;/code&gt;. If you had &lt;code&gt;from python import ...&lt;/code&gt;, it would pick &lt;code&gt;python:3.12-slim-bookworm&lt;/code&gt; over distroless. &lt;strong&gt;It generates what you'd write by hand&lt;/strong&gt;, you commit it, you edit it when you need to.&lt;/p&gt;

&lt;p&gt;Now you ship:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Build the image, push to a registry.&lt;/span&gt;
fitz deploy docker &lt;span class="nt"&gt;--tag&lt;/span&gt; mycorp/shortener:v1

&lt;span class="c"&gt;# Or bring up locally with compose (handy for local QA).&lt;/span&gt;
fitz deploy compose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fitz deploy&lt;/code&gt; is a thin wrapper over the native &lt;code&gt;docker&lt;/code&gt;/&lt;code&gt;docker compose&lt;/code&gt; CLIs. The point isn't to invent new tools — it's that you stop chasing wrong Dockerfile mistakes for two days every time you start a project. The right ones are already there.&lt;/p&gt;

&lt;p&gt;If you want the binary deployment without Docker, you can keep doing what we did in step 7 — &lt;code&gt;scp&lt;/code&gt; the binary, run it. The &lt;code&gt;fitz build&lt;/code&gt; output is genuinely standalone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Production niceties: healthchecks, secrets, observability
&lt;/h3&gt;

&lt;p&gt;While we're talking about production, Fitz already has the rest of the production checklist as part of the language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@healthz
fn liveness() -&amp;gt; Bool =&amp;gt; true

@readyz
async fn readiness(db: DbConn) -&amp;gt; Bool {
    return match db.exec("SELECT 1").await {
        Ok(_) =&amp;gt; true,
        Err(_) =&amp;gt; false,
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/healthz&lt;/code&gt; and &lt;code&gt;/readyz&lt;/code&gt; auto-mount on the HTTP router. Kubernetes is happy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let JWT_SECRET: Secret&amp;lt;Str&amp;gt; = secret("JWT_SECRET")  // never prints, never logs the value
let LOG_LEVEL: Str = config("LOG_LEVEL", "info")    // typed env var with default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; is an opaque type — &lt;code&gt;print(JWT_SECRET)&lt;/code&gt; prints &lt;code&gt;"***"&lt;/code&gt;, JSON serialization redacts it, the &lt;code&gt;log.info(...)&lt;/code&gt; calls strip it from structured fields. The only way to expose the value is &lt;code&gt;.expose()&lt;/code&gt; — explicit and grep-able for code review.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Observability with one env var, zero code changes.&lt;/span&gt;
&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://jaeger:4318 ./url-shortener
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the env var is set, every HTTP request opens a span that exports to OpenTelemetry. &lt;code&gt;trace_id&lt;/code&gt;/&lt;code&gt;span_id&lt;/code&gt; are propagated to every &lt;code&gt;log.info(...)&lt;/code&gt; inside the handler — you grep Jaeger by &lt;code&gt;trace_id&lt;/code&gt; and instantly find every related log line. When the env var is &lt;strong&gt;not&lt;/strong&gt; set, there is zero network overhead — the exporter is a no-op.&lt;/p&gt;

&lt;p&gt;You don't have to bolt any of this on. It's already there.&lt;/p&gt;

&lt;h3&gt;
  
  
  How fast is the thing you just built?
&lt;/h3&gt;

&lt;p&gt;While we're talking production: the &lt;code&gt;api-postgres-python&lt;/code&gt; boilerplate in the repo implements the &lt;strong&gt;same shortener-shaped CRUD&lt;/strong&gt; that you wrote in this tutorial, but with FastAPI + SQLAlchemy + asyncpg. Same Postgres, same schema, same endpoints, same JSON shape, same &lt;code&gt;docker compose&lt;/code&gt;. From the reproducible bench on &lt;strong&gt;v0.10.13&lt;/strong&gt; (Intel Core Ultra 7 155H, Docker 29.2.1, 30s sustained, concurrency 10):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Fitz ORM&lt;/th&gt;
&lt;th&gt;Python + SQLAlchemy&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Memory peak&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.2 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;51 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.5× leaner&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /users&lt;/code&gt; p50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.88 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;37.85 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.76×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /users&lt;/code&gt; RPS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1944&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;246&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.91×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /users/{id}&lt;/code&gt; p50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.60 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;31.87 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.85×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /users/{id}&lt;/code&gt; RPS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2604&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;296&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.80×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold start&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.14 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.22 s&lt;/td&gt;
&lt;td&gt;1.57×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image size&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;131 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;258 MB&lt;/td&gt;
&lt;td&gt;2× lighter&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same machine, same Docker network, same Postgres. The binary you just compiled in step 7 is in that left column. Run &lt;code&gt;bash benchmarks/orm-vs-sqlalchemy/run.sh&lt;/code&gt; from the repo to reproduce on your hardware (~5–8 min with hot Docker cache; needs &lt;code&gt;oha&lt;/code&gt; + &lt;code&gt;jq&lt;/code&gt;). Full methodology and raw output in &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/benchmarks/orm-vs-sqlalchemy/README.md" rel="noopener noreferrer"&gt;&lt;code&gt;benchmarks/orm-vs-sqlalchemy/README.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you got
&lt;/h2&gt;

&lt;p&gt;A working URL shortener in ~120 lines of Fitz:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP server with OpenAPI 3.1 + Scalar UI.&lt;/li&gt;
&lt;li&gt;Postgres ORM with typed closure-to-SQL and &lt;strong&gt;&lt;code&gt;fitz db diff&lt;/code&gt;/&lt;code&gt;migrate&lt;/code&gt;&lt;/strong&gt; for schema.&lt;/li&gt;
&lt;li&gt;JWT auth + Argon2id passwords.&lt;/li&gt;
&lt;li&gt;Background jobs with &lt;code&gt;spawn&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Native binary built with &lt;code&gt;fitz build&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dockerfile + compose generated&lt;/strong&gt; from the AST with &lt;code&gt;fitz docker init&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz deploy&lt;/code&gt;&lt;/strong&gt; wrapping &lt;code&gt;docker build/push&lt;/code&gt; and &lt;code&gt;docker compose up&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Health checks via &lt;code&gt;@healthz&lt;/code&gt;/&lt;code&gt;@readyz&lt;/code&gt;, secrets via &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt;, observability via OpenTelemetry — all built in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you &lt;strong&gt;didn't&lt;/strong&gt; install:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;FastAPI, Uvicorn, Pydantic.&lt;/li&gt;
&lt;li&gt;SQLAlchemy, asyncpg, &lt;strong&gt;alembic&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;python-jose, passlib, argon2-cffi.&lt;/li&gt;
&lt;li&gt;Celery, Redis (for the spawn).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OpenTelemetry SDK + auto-instrumentation packages.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A Dockerfile linter, a compose generator, a deploy script.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total external dependencies of your project: &lt;strong&gt;0&lt;/strong&gt;. Just Fitz and Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's missing (for context)
&lt;/h2&gt;

&lt;p&gt;In a real shortener you'd still want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real users&lt;/strong&gt; — we hardcoded Ada. A real &lt;code&gt;User&lt;/code&gt; table with &lt;code&gt;@table&lt;/code&gt; is two minutes more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting per user&lt;/strong&gt; — middleware on top of &lt;code&gt;@authenticated&lt;/code&gt;. A &lt;code&gt;@middleware(rate_limit)&lt;/code&gt; plus a small Redis/Postgres counter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom analytics&lt;/strong&gt; — beyond click counter, you'd want user agent, referer, etc. Adds in 5 more lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background tracking with retry&lt;/strong&gt; — &lt;code&gt;spawn&lt;/code&gt; is fire-and-forget. For real retries, the &lt;code&gt;@cron&lt;/code&gt; jobs with persistence + retry config (&lt;code&gt;@cron("expr", retry={max: 5, backoff: "exponential"}, store=db)&lt;/code&gt;) are already supported — wire a &lt;code&gt;clicks_queue&lt;/code&gt; table and a &lt;code&gt;@cron&lt;/code&gt; that drains it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All achievable today with what's in the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  What comes next in the series
&lt;/h2&gt;

&lt;p&gt;In the next post we &lt;strong&gt;add WebSockets&lt;/strong&gt; to the shortener: a real-time dashboard that watches clicks live, with the same auth, with AsyncAPI generated automatically.&lt;/p&gt;

&lt;p&gt;If you got stuck somewhere or built something interesting, drop a note in &lt;a href="https://github.com/Thegreekman76/fitz/issues" rel="noopener noreferrer"&gt;issues on GitHub&lt;/a&gt; — I read every one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Final repo&lt;/strong&gt;: the full code of this tutorial is at &lt;a href="https://github.com/Thegreekman76/fitz/tree/main/boilerplates/api-orm-full" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/tree/main/boilerplates/api-orm-full&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docs and course&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Guide (34 chapters)&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz/guide/&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Roadmap&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/docs/roadmap.md" rel="noopener noreferrer"&gt;docs/roadmap.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CHANGELOG&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;CHANGELOG.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Until the next one.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>opensource</category>
      <category>tutorial</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Presentando Fitz: un lenguaje donde HTTP, Postgres, JWT y WebSockets son parte de la sintaxis</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Sat, 06 Jun 2026 11:09:56 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/presentando-fitz-un-lenguaje-donde-http-postgres-jwt-y-websockets-son-parte-de-la-sintaxis-3la8</link>
      <guid>https://dev.to/martin_palopoli/presentando-fitz-un-lenguaje-donde-http-postgres-jwt-y-websockets-son-parte-de-la-sintaxis-3la8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Fitz es un lenguaje de programación nuevo, escrito en Rust, con compilador de tipado gradual. La premisa: en lugar de apilar FastAPI + SQLAlchemy + python-jose + Celery + Pydantic + uvicorn + Alembic + typer sobre Python, lo que cada una resuelve vive &lt;strong&gt;adentro del lenguaje&lt;/strong&gt;: ruteo HTTP, generación de OpenAPI/AsyncAPI, async/await, autenticación con JWT, hashing de passwords, un ORM con driver Postgres escrito en Rust puro, migraciones de schema, WebSockets, cron, jobs en background, un CLI builder, healthchecks, observability con OpenTelemetry, secrets como tipos opacos, y un orquestador &lt;code&gt;fitz deploy&lt;/code&gt;. Un solo binario. Cero deps externas para el stack core. &lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt; · &lt;strong&gt;Docs&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Hace años que vengo escribiendo APIs en Python — FastAPI más el elenco habitual: SQLAlchemy, python-jose para JWT, passlib para Argon2, Celery + Redis para jobs, Pydantic para validación, uvicorn para servir, alembic para migraciones. Cada API que entrego necesita más o menos las mismas nueve librerías, cada una con sus convenciones, sus breaking changes, su forma de integrarse con el resto.&lt;/p&gt;

&lt;p&gt;En algún momento me hice la pregunta obvia: &lt;strong&gt;¿por qué no es esto el lenguaje y listo?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Esa pregunta es Fitz.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cómo se ve Fitz
&lt;/h2&gt;

&lt;p&gt;Empecemos por la foto, después caminamos las piezas.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(43928)
fn main() =&amp;gt; 0

type User { id: Int, email: Str, name: Str, role: Str }
type Credentials { email: Str, password: Str }
type LoginResponse { token: Str }

let SECRET = "demo-secret-cambiame-en-prod"
let ADA_HASH = hash.password("secret-ada-123")

@auth_provider
fn check_token(headers: Map&amp;lt;Str, Str&amp;gt;) -&amp;gt; Result&amp;lt;User&amp;gt; {
    let auth: Str = match headers.get("authorization") {
        Ok(v) =&amp;gt; v,
        Err(_) =&amp;gt; return Err("falta header Authorization"),
    }
    let parts = auth.split(" ")
    if (parts.len() != 2 or parts[0] != "Bearer") {
        return Err("se esperaba 'Bearer &amp;lt;token&amp;gt;'")
    }
    let claims = jwt.decode(parts[1], SECRET)?
    return find_user(claims["email"])
}

@post("/login")
fn login(creds: Credentials) -&amp;gt; LoginResponse {
    let user: User = match find_user(creds.email) {
        Ok(u) =&amp;gt; u,
        Err(_) =&amp;gt; return 401 { "error": "credenciales inválidas" },
    }
    if (not hash.verify(creds.password, ADA_HASH)) {
        return 401 { "error": "credenciales inválidas" }
    }
    let claims = { "email": user.email, "role": user.role }
    return LoginResponse { token: jwt.encode(claims, SECRET) }
}

@authenticated
@get("/me")
fn me(user: User) -&amp;gt; User =&amp;gt; user

@admin
@get("/admin/users")
fn admin_list(user: User) -&amp;gt; List&amp;lt;User&amp;gt; { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lo que hace este código, &lt;strong&gt;sin un solo &lt;code&gt;import&lt;/code&gt; ni una dependencia externa&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Levanta un servidor HTTP en el puerto 43928.&lt;/li&gt;
&lt;li&gt;Genera OpenAPI 3.1 automático en &lt;code&gt;/openapi.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Sirve la UI de Scalar en &lt;code&gt;/docs&lt;/code&gt; con botón "Authorize" funcional.&lt;/li&gt;
&lt;li&gt;Firma y verifica JWT (HS256/384/512).&lt;/li&gt;
&lt;li&gt;Hashea passwords con &lt;strong&gt;Argon2id&lt;/strong&gt; (recomendación de OWASP, no bcrypt).&lt;/li&gt;
&lt;li&gt;Valida estáticamente que cada &lt;code&gt;@authenticated&lt;/code&gt;/&lt;code&gt;@admin&lt;/code&gt; tenga un &lt;code&gt;@auth_provider&lt;/code&gt; declarado, que el provider devuelva el tipo &lt;code&gt;User&lt;/code&gt; correcto, y que los handlers &lt;code&gt;@admin&lt;/code&gt; tengan un campo &lt;code&gt;role: Str&lt;/code&gt; en el &lt;code&gt;User&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Compila a un binario nativo con &lt;code&gt;fitz build&lt;/code&gt;, con paridad bit-a-bit contra &lt;code&gt;fitz run&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La auth, el hashing, el JWT, el OpenAPI con el security scheme &lt;code&gt;bearerAuth&lt;/code&gt;, las respuestas 401/403 — todo eso vive adentro del binario &lt;code&gt;fitz&lt;/code&gt;. No hay &lt;code&gt;requirements.txt&lt;/code&gt;, no hay &lt;code&gt;package.json&lt;/code&gt;, no hay &lt;code&gt;Cargo.toml&lt;/code&gt; del lado del usuario.&lt;/p&gt;

&lt;h2&gt;
  
  
  Por qué "ciudadano de primera" importa
&lt;/h2&gt;

&lt;p&gt;"Ciudadano de primera clase" es una de esas frases que se gastan rápido. Lo digo concreto.&lt;/p&gt;

&lt;p&gt;En FastAPI, &lt;code&gt;@app.get("/users")&lt;/code&gt; es un método sobre una instancia. El framework es una librería de la cual hacés opt-in. El router es una estructura de datos Python. La autenticación es un &lt;code&gt;Depends(...)&lt;/code&gt;. Nada de eso es visible para el type checker como algo especial — son simplemente llamadas a funciones y decoradores que producen metadata.&lt;/p&gt;

&lt;p&gt;En Fitz, &lt;code&gt;@get("/users")&lt;/code&gt; es un &lt;strong&gt;decorador que el compilador entiende&lt;/strong&gt;. El checker valida el template del path, los tipos de los parámetros contra los path params, el tipo del body, el tipo de retorno. El generador de OpenAPI inspecciona el AST directamente — no introspeciona objetos de runtime, no necesita decoradores que se "registren" a sí mismos. El &lt;code&gt;User&lt;/code&gt; que devolvés en tu handler es el mismo &lt;code&gt;User&lt;/code&gt; que aparece en el schema generado y en la UI de Scalar.&lt;/p&gt;

&lt;p&gt;Suena chico hasta que lo vivís una semana. Después dejás de pelear con "por qué Pydantic discrepa con SQLAlchemy sobre si este campo es opcional" y empezás a escribir endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Las piezas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  HTTP + OpenAPI + UI Scalar, todo automático
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type Post { id: Int, title: Str, body: Str, tags: List&amp;lt;Str&amp;gt; }

@get("/posts")
fn list_posts() -&amp;gt; List&amp;lt;Post&amp;gt; { ... }

@post("/posts")
fn create_post(post: Post) -&amp;gt; Post { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eso es todo lo que necesitás. &lt;code&gt;/openapi.json&lt;/code&gt; y &lt;code&gt;/docs&lt;/code&gt; (Scalar) aparecen solos. Path params (&lt;code&gt;/posts/{id}&lt;/code&gt;) son tipados y coercionados. La deserialización del JSON del body chequea required, aplica defaults, valida nullables, rechaza campos extra. Podés desactivar con &lt;code&gt;@server(docs=false)&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  WebSockets tipados, con AsyncAPI auto-generado
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type ChatMessage { from: Str, text: Str }

@server(43929, ws_heartbeat_secs=30)
fn main() =&amp;gt; 0

@authenticated
@ws("/chat")
async fn chat(conn: WsConn&amp;lt;ChatMessage&amp;gt;, user: User) {
    loop {
        let msg = match conn.recv() {
            Ok(m) =&amp;gt; m,
            Err(_) =&amp;gt; break,
        }
        conn.broadcast(ChatMessage { from: user.name, text: msg.text })
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cada frame se marshallea automáticamente desde y hacia el tipo declarado. La auth corre &lt;strong&gt;antes&lt;/strong&gt; del upgrade WebSocket — token inválido devuelve 401 sin abrir el socket. El heartbeat con ping/pong mantiene la conexión viva más allá de los 60s default de Nginx. &lt;code&gt;/asyncapi.json&lt;/code&gt; se genera solo (la spec hermana de OpenAPI para APIs event-driven). No conozco otro lenguaje que auto-genere AsyncAPI desde el código fuente tipado.&lt;/p&gt;

&lt;h3&gt;
  
  
  Jobs en background y cron, sin Redis
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@cron("*/5 * * * *")
async fn cleanup_old_sessions() {
    db.exec("DELETE FROM sessions WHERE expires_at &amp;lt; now()")
}

@background
async fn send_welcome_email(email: Str) {
    // cosa cara
}

@post("/signup")
fn signup(creds: Credentials) -&amp;gt; User {
    let user = create_user(creds)
    spawn(send_welcome_email(user.email))  // fire-and-forget, Future&amp;lt;Null&amp;gt; tipado
    return user
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sin Celery. Sin Redis. Sin &lt;code&gt;celery worker -A app&lt;/code&gt; corriendo al lado del &lt;code&gt;uvicorn&lt;/code&gt;. El scheduler está en tu binario. Suficiente para el 90% de servicios — cuando lo superás, lo superás por una razón concreta, y eso es problema de Fase 11+.&lt;/p&gt;

&lt;h3&gt;
  
  
  Un ORM nativo con driver Postgres en Rust puro
&lt;/h3&gt;

&lt;p&gt;Esta es la pieza de la que más orgulloso estoy, y la que más tiempo me llevó. Fitz tiene su propio driver de Postgres escrito en Rust — sin &lt;code&gt;libpq&lt;/code&gt;, sin &lt;code&gt;tokio-postgres&lt;/code&gt;, sin &lt;code&gt;sqlx&lt;/code&gt;. El protocolo wire (v3.0), auth SCRAM-SHA-256, prepared statements, formato binario para 11 tipos OID — todo implementado desde el RFC.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@table("users")
type User {
    @primary id: Int,
    email: Str,
    name: Str,
    @has_many("Post", "user_id") posts: List&amp;lt;Post&amp;gt;,
}

@table("posts")
type Post {
    @primary id: Int,
    user_id: Int,
    title: Str,
    body: Str,
    @belongs_to user: User?,
}

@get("/users")
async fn list_users(db: DbConn) -&amp;gt; List&amp;lt;User&amp;gt; {
    return User.all(db).preload("posts").await
}

@get("/users/{id}")
async fn get_user(db: DbConn, id: Int) -&amp;gt; Result&amp;lt;User&amp;gt; {
    return User.where(fn(u) =&amp;gt; u.id == id).first(db).await
}

@post("/users")
async fn create_user(db: DbConn, user: User) -&amp;gt; User {
    return User.insert(db, user).await
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La closure adentro de &lt;code&gt;.where(...)&lt;/code&gt; se &lt;strong&gt;traduce a SQL parametrizado en tiempo de compilación&lt;/strong&gt; — &lt;code&gt;fn(u) =&amp;gt; u.id == id&lt;/code&gt; se convierte en &lt;code&gt;WHERE id = $1&lt;/code&gt;. Operadores como &lt;code&gt;.is_in([...])&lt;/code&gt;, &lt;code&gt;.like(...)&lt;/code&gt;, &lt;code&gt;.ilike(...)&lt;/code&gt;, &lt;code&gt;.contains(...)&lt;/code&gt;, más operadores JSONB como &lt;code&gt;.has_key(...)&lt;/code&gt;, &lt;code&gt;.contains_json(...)&lt;/code&gt; mapean a operadores nativos de Postgres. El eager loading con &lt;code&gt;.preload("posts")&lt;/code&gt; dispara una sola query batched. Agregados (&lt;code&gt;.sum&lt;/code&gt;/&lt;code&gt;.avg&lt;/code&gt;/&lt;code&gt;.min&lt;/code&gt;/&lt;code&gt;.max&lt;/code&gt;/&lt;code&gt;.count&lt;/code&gt;) y &lt;code&gt;GROUP BY&lt;/code&gt; están soportados a través de un tipo separado &lt;code&gt;Aggregated&amp;lt;Row&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Esto compila a código nativo vía &lt;code&gt;fitz build&lt;/code&gt;. El binario generado hace exactamente las mismas calls a Postgres. Cero overhead de runtime para el SQL — ya es constante en build-time, comparable en performance con Diesel o sqlx.&lt;/p&gt;

&lt;h4&gt;
  
  
  ¿Cuán rápido es de verdad? — cabeza-a-cabeza contra SQLAlchemy
&lt;/h4&gt;

&lt;p&gt;La promesa "cero overhead" es fácil de decir y fácil de inflar, así que el repo trae un &lt;strong&gt;bench reproducible&lt;/strong&gt; entre dos boilerplates equivalentes (&lt;a href="https://github.com/Thegreekman76/fitz/tree/main/boilerplates/api-postgres-fitz" rel="noopener noreferrer"&gt;&lt;code&gt;api-postgres-fitz&lt;/code&gt;&lt;/a&gt; vs &lt;a href="https://github.com/Thegreekman76/fitz/tree/main/boilerplates/api-postgres-python" rel="noopener noreferrer"&gt;&lt;code&gt;api-postgres-python&lt;/code&gt;&lt;/a&gt;) — mismo Postgres, mismos endpoints, mismo shape de respuesta, mismo &lt;code&gt;docker compose&lt;/code&gt;. Números headline en &lt;strong&gt;v0.10.13&lt;/strong&gt; (Intel Core Ultra 7 155H, Docker 29.2.1, 30s sostenidos, concurrencia 10):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Métrica&lt;/th&gt;
&lt;th&gt;Fitz ORM&lt;/th&gt;
&lt;th&gt;Python + SQLAlchemy&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Memory peak&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.2 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;51 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.5× más eficiente&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /users&lt;/code&gt; p50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.88 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;37.85 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.76×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /users&lt;/code&gt; RPS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1944&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;246&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.91×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /users/{id}&lt;/code&gt; p50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.60 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;31.87 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.85×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /users/{id}&lt;/code&gt; RPS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2604&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;296&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.80×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold start&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.14 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.22 s&lt;/td&gt;
&lt;td&gt;1.57×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image size&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;131 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;258 MB&lt;/td&gt;
&lt;td&gt;2× más liviano&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eso es ~8× el throughput con ~5× menos memoria, en la misma máquina, sobre la misma red Docker, contra el mismo Postgres. Reproducí los números con &lt;a href="https://github.com/Thegreekman76/fitz/tree/main/benchmarks/orm-vs-sqlalchemy" rel="noopener noreferrer"&gt;&lt;code&gt;bash benchmarks/orm-vs-sqlalchemy/run.sh&lt;/code&gt;&lt;/a&gt; (~5–8 min con cache Docker caliente; requiere &lt;code&gt;oha&lt;/code&gt; + &lt;code&gt;jq&lt;/code&gt;). Metodología completa, output crudo y las partes donde la comparación es injusta para Fitz están en el &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/benchmarks/orm-vs-sqlalchemy/README.md" rel="noopener noreferrer"&gt;README del bench&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from python import math, json

let radius = 5.0
let area: Float = math.pi * radius * radius

let parsed: Result&amp;lt;Map&amp;lt;Str, Any&amp;gt;&amp;gt; = match json.loads("{\"name\": \"ada\"}") {
    Ok(d) =&amp;gt; Ok(d),
    Err(e) =&amp;gt; Err("JSON malformado: {e}"),
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQLAlchemy, NumPy, pandas, lo que esté en PyPI — accesible desde Fitz con &lt;code&gt;from python import ...&lt;/code&gt;. El runtime embebe CPython vía PyO3. Las excepciones Python se vuelven &lt;code&gt;Result::Err&lt;/code&gt; automáticamente. Async Python (&lt;code&gt;asyncpg&lt;/code&gt;, SQLAlchemy 2.x async) se bridgea al &lt;code&gt;.await&lt;/code&gt; de Fitz transparente. Incluso podés hacer &lt;code&gt;fitz build --bundle-python&lt;/code&gt; para entregar un binario con CPython embebido — no se necesita Python en la máquina destino.&lt;/p&gt;

&lt;p&gt;Esto es intencional. Fitz no quiere reemplazar el ecosistema de Python — quiere darte un lenguaje mejor para la capa web mientras dejás abierta la puerta a todo lo que Python ya construyó.&lt;/p&gt;

&lt;h3&gt;
  
  
  Async, finalmente sin color
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async fn fetch_user(id: Int) -&amp;gt; Result&amp;lt;User&amp;gt; { ... }

async fn main() {
    let user = fetch_user(42).await?
    print("llegó {user.name}")
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;async&lt;/code&gt;/&lt;code&gt;await&lt;/code&gt; en el core, sobre runtime tokio. El operador &lt;code&gt;?&lt;/code&gt; funciona a través de &lt;code&gt;Result&amp;lt;T&amp;gt;&lt;/code&gt;. El type checker exige que &lt;code&gt;?&lt;/code&gt; solo aparezca adentro de funciones que retornan &lt;code&gt;Result&amp;lt;...&amp;gt;&lt;/code&gt;. Compila a &lt;code&gt;async fn&lt;/code&gt; + &lt;code&gt;.await&lt;/code&gt; en Rust — mismo modelo de ejecución, mismo executor multi-thread.&lt;/p&gt;

&lt;h3&gt;
  
  
  CLI builder — el mismo lenguaje, herramientas de línea de comandos
&lt;/h3&gt;

&lt;p&gt;Fitz no es solo para servicios HTTP. El mismo compilador trae un CLI builder built-in, sin librerías:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@command("greet", desc="Saludar a una persona")
fn greet(name: Str, loud: Bool = false, count: Int = 1) -&amp;gt; Int {
    let n = count
    while n &amp;gt; 0 {
        if loud { print("HOLA, {name}!") } else { print("hola, {name}") }
        n = n - 1
    }
    return 0
}

@command("add", desc="Sumar dos números")
fn add(a: Int, b: Int) -&amp;gt; Int {
    print("{a + b}")
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin greet Ada &lt;span class="nt"&gt;--loud&lt;/span&gt; &lt;span class="nt"&gt;--count&lt;/span&gt; 3
HOLA, Ada!
HOLA, Ada!
HOLA, Ada!

&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin &lt;span class="nt"&gt;--help&lt;/span&gt;
USAGE: mybin &amp;lt;&lt;span class="nb"&gt;command&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;ARGS] &lt;span class="o"&gt;[&lt;/span&gt;OPTIONS]
COMMANDS:
    greet    Saludar a una persona
    add      Sumar dos números
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Convención sobre decoración: params sin default son positional args, params con default son flags. &lt;code&gt;Bool&lt;/code&gt; con &lt;code&gt;default = false&lt;/code&gt; se vuelve &lt;code&gt;--flag&lt;/code&gt;, otros tipos &lt;code&gt;--flag &amp;lt;value&amp;gt;&lt;/code&gt;. Short flags auto-derivados (&lt;code&gt;--loud&lt;/code&gt; → &lt;code&gt;-l&lt;/code&gt;) con detección de conflictos. Help auto-generado, exit codes POSIX estándar. &lt;strong&gt;Paridad bit-a-bit&lt;/strong&gt; entre &lt;code&gt;fitz run&lt;/code&gt; (desarrollo) y &lt;code&gt;fitz build&lt;/code&gt; (binario self-contained que dropeás en &lt;code&gt;/usr/local/bin&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Es el mismo lenguaje. Mismo type checker. Mismo async/await. Mismo &lt;code&gt;Result&amp;lt;T&amp;gt;&lt;/code&gt; para errores. Si tu herramienta necesita pegar a la DB, el ORM está ahí. Si necesita HTTP, &lt;code&gt;@get&lt;/code&gt;/&lt;code&gt;@post&lt;/code&gt; están ahí. La línea entre "servicio web" y "CLI tool" deja de ser una decisión de stack.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stack production-ready — del repo a producción
&lt;/h3&gt;

&lt;p&gt;Esto es lo que separa a Fitz de los lenguajes "prototipo interesante". Los servicios reales necesitan health checks, secrets, observability, y una forma de shippear. Todo eso es parte del lenguaje:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(43928)
fn main() =&amp;gt; 0

// Auto-montados en GET /healthz y /readyz — Kubernetes-friendly.
@healthz
fn liveness() -&amp;gt; Bool =&amp;gt; true

@readyz
async fn readiness(db: DbConn) -&amp;gt; Bool {
    return match db.exec("SELECT 1").await {
        Ok(_) =&amp;gt; true,
        Err(_) =&amp;gt; false,
    }
}

// Secret&amp;lt;T&amp;gt; nunca leakea a logs, imprime "***" en Display.
let db_url: Secret&amp;lt;Str&amp;gt; = secret("DATABASE_URL")
let log_level: Str = config("LOG_LEVEL", "info")

// Tracing + métricas con un decorador cada uno.
@trace(name="process_order")
@metric(name="orders")
async fn process(order: Order) -&amp;gt; Result&amp;lt;Receipt&amp;gt; {
    // process_order_duration_seconds (histogram) y orders_calls_total
    // (counter) se populan automático al hacer drop del scope.
}

// Feature flags con dos fuentes: fitz.toml [flags] + env vars FITZ_FLAG_&amp;lt;NAME&amp;gt;.
@flag("new-checkout")
@post("/v2/checkout")
fn v2_checkout(body: Cart) -&amp;gt; Receipt { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Atrás de bambalinas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HTTP access logs&lt;/strong&gt; auto-emitidos con &lt;code&gt;trace_id&lt;/code&gt;/&lt;code&gt;span_id&lt;/code&gt; propagado a cada &lt;code&gt;log.info(...)&lt;/code&gt; adentro del handler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export OpenTelemetry OTLP&lt;/strong&gt; con un solo env var: &lt;code&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/code&gt;. Los spans fluyen a Jaeger/Tempo/Honeycomb. Sin el env var, cero overhead, cero llamadas de red.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Endpoint &lt;code&gt;/metrics&lt;/code&gt;&lt;/strong&gt; Prometheus expone counters y histogramas — &lt;code&gt;@server(prometheus=true)&lt;/code&gt; lo activa.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@flag&lt;/code&gt; sobre handlers HTTP/WS&lt;/strong&gt; retorna 404 cuando la flag está off — gate del hot path ANTES de middleware/auth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deployando:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Genera Dockerfile + docker-compose.yml a partir del shape del programa.&lt;/span&gt;
fitz docker init

&lt;span class="c"&gt;# Build del binario, build de imagen Docker, push al registry.&lt;/span&gt;
fitz deploy docker &lt;span class="nt"&gt;--tag&lt;/span&gt; mycorp/api:v1

&lt;span class="c"&gt;# O levantá local con compose.&lt;/span&gt;
fitz deploy compose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fitz docker init&lt;/code&gt; lee tu AST. Si hay &lt;code&gt;db.connect(...)&lt;/code&gt;, agrega Postgres al compose. Si hay &lt;code&gt;@server(N)&lt;/code&gt;, setea &lt;code&gt;EXPOSE N&lt;/code&gt;. Si hay &lt;code&gt;@cron&lt;/code&gt;, agrega &lt;code&gt;restart: unless-stopped&lt;/code&gt;. Si hay &lt;code&gt;from python import ...&lt;/code&gt;, elige &lt;code&gt;python:3.12-slim-bookworm&lt;/code&gt; en lugar de distroless. &lt;strong&gt;Genera lo que vos escribirías a mano&lt;/strong&gt;, lo commiteás, lo editás cuando lo necesites.&lt;/p&gt;

&lt;p&gt;No conozco otro lenguaje donde deployment sea una feature del lenguaje. Acá lo es porque cada proyecto que entregué en Python terminaba con dos días debuggeando gotchas del Dockerfile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Las herramientas
&lt;/h2&gt;

&lt;p&gt;Esta es la parte que subestimé cuando arranqué. Un lenguaje sin buenas herramientas nace muerto. Acá lo que hay hoy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz run&lt;/code&gt;&lt;/strong&gt; — interpreta el archivo directo. El ciclo de feedback más rápido.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz build&lt;/code&gt;&lt;/strong&gt; — compila a binario nativo vía un proyecto Rust generado. La paridad bit-a-bit con &lt;code&gt;fitz run&lt;/code&gt; es un requisito duro.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz check&lt;/code&gt;&lt;/strong&gt; — solo type checker, sin ejecución.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz test&lt;/code&gt;&lt;/strong&gt; — test runner built-in con decorador &lt;code&gt;@test&lt;/code&gt; y &lt;code&gt;assert&lt;/code&gt;, &lt;code&gt;assert_eq&lt;/code&gt;, &lt;code&gt;assert_throws&lt;/code&gt;. Output estilo cargo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz dev&lt;/code&gt;&lt;/strong&gt; — hot reload. Watchea &lt;code&gt;*.fitz&lt;/code&gt; y &lt;code&gt;fitz.toml&lt;/code&gt;, mata y respawnea el child al cambio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz fmt&lt;/code&gt;&lt;/strong&gt; — formatter opinionado, cero config. Preserva tus comentarios y líneas en blanco.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz lint&lt;/code&gt;&lt;/strong&gt; — 4 lints built-in con supresión &lt;code&gt;// @allow(&amp;lt;nombre&amp;gt;)&lt;/code&gt;. Output estilo cargo-clippy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz repl&lt;/code&gt;&lt;/strong&gt; — REPL interactivo con soporte multi-línea, &lt;code&gt;:type&lt;/code&gt;, &lt;code&gt;:load&lt;/code&gt;, historial persistente.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz openapi&lt;/code&gt;&lt;/strong&gt; — emite el schema OpenAPI sin levantar el server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz db diff&lt;/code&gt;/&lt;code&gt;migrate&lt;/code&gt;&lt;/strong&gt; — tooling de migraciones de schema. Diff entre la DB viva y los types &lt;code&gt;@table&lt;/code&gt; en tu código, genera migraciones idempotentes, las aplicás con &lt;code&gt;fitz db migrate&lt;/code&gt;. Mismo modelo que Alembic pero con los types como fuente de verdad.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz docker init&lt;/code&gt;/&lt;code&gt;build&lt;/code&gt;&lt;/strong&gt; — genera el Dockerfile + &lt;code&gt;.dockerignore&lt;/code&gt; + &lt;code&gt;docker-compose.yml&lt;/code&gt; a partir del shape del programa, después &lt;code&gt;docker build&lt;/code&gt; wrappeado.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz deploy docker&lt;/code&gt;/&lt;code&gt;compose&lt;/code&gt;&lt;/strong&gt; — wrapper fino para shippear la imagen o levantar local con un solo comando.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extensión VSCode&lt;/strong&gt; — diagnostics + hover + go-to-definition + autocomplete + &lt;strong&gt;signature help&lt;/strong&gt; + &lt;strong&gt;format on save&lt;/strong&gt; + &lt;strong&gt;inferencia bidireccional&lt;/strong&gt; para callbacks, distribución multi-platform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz new&lt;/code&gt;&lt;/strong&gt; + &lt;strong&gt;&lt;code&gt;fitz add&lt;/code&gt;&lt;/strong&gt; + &lt;strong&gt;&lt;code&gt;fitz remove&lt;/code&gt;&lt;/strong&gt; + &lt;strong&gt;&lt;code&gt;fitz update&lt;/code&gt;&lt;/strong&gt; — package manager con &lt;code&gt;fitz.toml&lt;/code&gt;, lockfile, path deps, git deps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;El LSP es real (&lt;code&gt;tower-lsp&lt;/code&gt; adentro). El formatter es real (tu código hace round-trip por él). El test runner es real. Todo está dogfooded — escribo código Fitz con la misma extensión VSCode que entrego.&lt;/p&gt;

&lt;h2&gt;
  
  
  Siendo honesto sobre el estado
&lt;/h2&gt;

&lt;p&gt;Esto es un proyecto de un solo desarrollador. Empecé aprendiendo Rust para construirlo. No voy a fingir que está listo para producción para cualquiera — esto es lo verdadero hoy (junio 2026, release v0.15.0):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lo que funciona end-to-end, con paridad bit-a-bit &lt;code&gt;fitz run&lt;/code&gt; ↔ &lt;code&gt;fitz build&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server HTTP con &lt;code&gt;@get&lt;/code&gt;/&lt;code&gt;@post&lt;/code&gt;/&lt;code&gt;@put&lt;/code&gt;/&lt;code&gt;@delete&lt;/code&gt;, OpenAPI auto, UI Scalar.&lt;/li&gt;
&lt;li&gt;Chain de middleware con &lt;code&gt;@middleware(fn)&lt;/code&gt; + CORS built-in.&lt;/li&gt;
&lt;li&gt;Auth con JWT con &lt;code&gt;@auth_provider&lt;/code&gt;/&lt;code&gt;@authenticated&lt;/code&gt;/&lt;code&gt;@admin&lt;/code&gt; + &lt;code&gt;@requires("role_custom")&lt;/code&gt; para RBAC. Hashing de passwords con Argon2id. Token blacklist sobre Postgres para logout/refresh.&lt;/li&gt;
&lt;li&gt;WebSockets con &lt;code&gt;WsConn&amp;lt;T&amp;gt;&lt;/code&gt;, AsyncAPI auto, heartbeat, auth pre-upgrade.&lt;/li&gt;
&lt;li&gt;Cron jobs con &lt;code&gt;@cron("expr")&lt;/code&gt; (con retry, timezone, persistencia, catch-up), jobs background con &lt;code&gt;@background&lt;/code&gt; + &lt;code&gt;spawn(...)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;ORM Postgres con &lt;code&gt;@table&lt;/code&gt;/&lt;code&gt;@primary&lt;/code&gt;/&lt;code&gt;@column&lt;/code&gt;/&lt;code&gt;@belongs_to&lt;/code&gt;/&lt;code&gt;@has_many&lt;/code&gt;, closure-to-SQL, eager loading, &lt;strong&gt;transacciones&lt;/strong&gt; (&lt;code&gt;db.transaction(fn)&lt;/code&gt;), &lt;strong&gt;migraciones de schema&lt;/strong&gt; (&lt;code&gt;fitz db diff&lt;/code&gt;/&lt;code&gt;migrate&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;TLS estricto para Postgres (&lt;code&gt;sslmode=require&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Async/await sobre tokio.&lt;/li&gt;
&lt;li&gt;Interop Python con &lt;code&gt;from python import ...&lt;/code&gt;, incluyendo bridge automático para async.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLI builder&lt;/strong&gt; con &lt;code&gt;@command&lt;/code&gt; — mismo lenguaje para CLI tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stack production&lt;/strong&gt;: &lt;code&gt;@healthz&lt;/code&gt;/&lt;code&gt;@readyz&lt;/code&gt;, &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt;, &lt;code&gt;secret()&lt;/code&gt;/&lt;code&gt;config()&lt;/code&gt;, &lt;code&gt;@trace&lt;/code&gt;/&lt;code&gt;@metric&lt;/code&gt;, &lt;code&gt;@flag&lt;/code&gt;, export OpenTelemetry OTLP, endpoint Prometheus &lt;code&gt;/metrics&lt;/code&gt;, &lt;code&gt;fitz docker init/build&lt;/code&gt;, &lt;code&gt;fitz deploy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Package manager con path deps y git deps.&lt;/li&gt;
&lt;li&gt;Tooling completo: LSP (con signature help, format on save, hover sobre params y bindings), fmt, test, dev, repl, lint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Lo que todavía no está en la caja:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Frontend en &lt;code&gt;.fitz&lt;/code&gt; (single-file components, SSR). Roadmap (Fase 11) — la apuesta más ambiciosa del proyecto. Sin arrancar.&lt;/li&gt;
&lt;li&gt;Un registry público de paquetes. Path deps y git deps funcionan hoy; el registry está en pausa hasta que aparezca demanda real.&lt;/li&gt;
&lt;li&gt;Targets de &lt;code&gt;fitz deploy&lt;/code&gt; más allá de &lt;code&gt;docker&lt;/code&gt;/&lt;code&gt;compose&lt;/code&gt; (todavía no hay wrapper de &lt;code&gt;fly&lt;/code&gt;/&lt;code&gt;railway&lt;/code&gt;/&lt;code&gt;k8s&lt;/code&gt; — usá los CLIs nativos).&lt;/li&gt;
&lt;li&gt;Debugging interactivo en VSCode (Debug Adapter Protocol). Workarounds: &lt;code&gt;print&lt;/code&gt;, REPL &lt;code&gt;:type&lt;/code&gt;/&lt;code&gt;:env&lt;/code&gt;, diagnostics LSP. Trackeado como V6 en el backlog.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Lo que es estable&lt;/strong&gt;: ~3030 tests unit de Rust + 13 LSP E2E + 360 compile E2E (smoke sobre cada ejemplo de la guía) + ~140 más entre otras suites corriendo en CI en cada push. Clippy &lt;code&gt;-D warnings&lt;/code&gt; limpio.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cómo probarlo
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Instalación en Linux / macOS / WSL&lt;/span&gt;
curl &lt;span class="nt"&gt;-sSf&lt;/span&gt; https://thegreekman76.github.io/fitz/install.sh | sh

&lt;span class="c"&gt;# Instalación en Windows (PowerShell)&lt;/span&gt;
irm https://thegreekman76.github.io/fitz/install.ps1 | iex

&lt;span class="c"&gt;# O bajá un binario desde GitHub&lt;/span&gt;
&lt;span class="c"&gt;# https://github.com/Thegreekman76/fitz/releases&lt;/span&gt;

&lt;span class="c"&gt;# Reabrí la terminal para que el cambio de PATH aplique, después:&lt;/span&gt;
fitz &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Extensión VSCode&lt;/strong&gt; (recomendado — syntax highlighting, hover con tipos, autocomplete, signature help, format on save): bajá el &lt;code&gt;.vsix&lt;/code&gt; de tu plataforma desde la misma &lt;a href="https://github.com/Thegreekman76/fitz/releases" rel="noopener noreferrer"&gt;página de releases&lt;/a&gt; (&lt;code&gt;fitz-lang-&amp;lt;plataforma&amp;gt;.vsix&lt;/code&gt;) e instalala con &lt;code&gt;code --install-extension fitz-lang-&amp;lt;plataforma&amp;gt;.vsix --force&lt;/code&gt;. El Language Server viene incluido — no hace falta instalarlo aparte. Recargá VSCode una vez.&lt;/p&gt;

&lt;p&gt;Primer server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz new mi-api &lt;span class="nt"&gt;--http&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;mi-api
fitz dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vienen ocho boilerplates en el repo bajo &lt;code&gt;boilerplates/&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;api-simple&lt;/code&gt; — API HTTP mínima.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api-middleware-cors&lt;/code&gt; — chain de middleware + config de CORS.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api-postgres-fitz&lt;/code&gt; — ORM + Postgres, Dockerizado.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api-postgres-python&lt;/code&gt; — Postgres vía interop Python/SQLAlchemy.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api-websocket&lt;/code&gt; — chat WebSocket tipado.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api-orm-full&lt;/code&gt; — el showcase completo: auth + ORM + WebSockets + cron + jobs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api-fullstack-postgres&lt;/code&gt; — backend + frontend mínimo en un binario.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cli-tool&lt;/code&gt; — app CLI con &lt;code&gt;@command&lt;/code&gt; (sin HTTP).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cada uno corre con &lt;code&gt;docker compose up&lt;/code&gt; o &lt;code&gt;fitz dev&lt;/code&gt;. El README tiene la matriz completa.&lt;/p&gt;

&lt;h2&gt;
  
  
  Por qué construí esto
&lt;/h2&gt;

&lt;p&gt;Vivo en El Chaltén, en la Patagonia argentina. El Fitz Roy es la torre de granito que define el horizonte acá. Borges escribió que vivimos en un país donde el pasado es incierto y solo el futuro es real. Creo que también vale para los lenguajes de programación: el pasado está lleno de workarounds acumulados por features que faltan en el lenguaje, y el futuro es lo que vos decidís construir.&lt;/p&gt;

&lt;p&gt;Llevo diez años escribiendo código de APIs en Python. Amo FastAPI. Pero cada vez que arranco un proyecto nuevo, las primeras tres horas se van pegando librerías para hacer lo mismo que hice la semana pasada. En algún punto la pregunta se vuelve: ¿cómo sería un lenguaje que arrancara desde este conjunto de necesidades en 2026, en lugar de hacerlas crecer como parches sobre un lenguaje diseñado para scripting de shell en 1991?&lt;/p&gt;

&lt;p&gt;Eso es Fitz.&lt;/p&gt;

&lt;p&gt;No está terminado. Soy uno solo. Va a llegar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Docs y curso&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Guía&lt;/strong&gt; (34 capítulos): &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz/guide/&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Roadmap&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/docs/roadmap.md" rel="noopener noreferrer"&gt;docs/roadmap.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CHANGELOG&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;CHANGELOG.md&lt;/a&gt; — cada release con detalle.&lt;br&gt;
&lt;strong&gt;Issues&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/issues" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/issues&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Si lo probás, quiero saber qué se rompió. Abrí un issue o una discussion en GitHub.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>opensource</category>
      <category>rust</category>
      <category>programming</category>
    </item>
    <item>
      <title>Introducing Fitz: a language where HTTP, Postgres, JWT, and WebSockets are part of the syntax</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Sat, 06 Jun 2026 11:07:43 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/introducing-fitz-a-language-where-http-postgres-jwt-and-websockets-are-part-of-the-syntax-4if0</link>
      <guid>https://dev.to/martin_palopoli/introducing-fitz-a-language-where-http-postgres-jwt-and-websockets-are-part-of-the-syntax-4if0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Fitz is a new programming language built in Rust, with a gradually-typed compiler. The pitch: instead of stacking FastAPI + SQLAlchemy + python-jose + Celery + Pydantic + uvicorn + Alembic + typer on top of Python, the things they each solve live &lt;strong&gt;inside the language&lt;/strong&gt;: HTTP routing, OpenAPI/AsyncAPI generation, async/await, JWT auth, password hashing, an ORM with a pure-Rust Postgres driver, schema migrations, WebSockets, cron, background jobs, a CLI builder, healthchecks, observability with OpenTelemetry, secrets as opaque types, and a &lt;code&gt;fitz deploy&lt;/code&gt; orchestrator. One binary. Zero external deps for the core stack. &lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt; · &lt;strong&gt;Docs&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz&lt;/a&gt;&amp;lt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I've been building web APIs in Python for years — FastAPI plus the usual cast: SQLAlchemy, python-jose for JWT, passlib for Argon2, Celery + Redis for background jobs, Pydantic for validation, uvicorn for serving, alembic for migrations. Every API I ship needs roughly the same nine libraries, each with its own conventions, its own breaking changes, its own way to integrate with the others.&lt;/p&gt;

&lt;p&gt;At some point I asked myself the obvious question: &lt;strong&gt;why isn't this just the language?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That question is Fitz.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Fitz looks like
&lt;/h2&gt;

&lt;p&gt;Let's start with the picture, then walk through the pieces.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(43928)
fn main() =&amp;gt; 0

type User { id: Int, email: Str, name: Str, role: Str }
type Credentials { email: Str, password: Str }
type LoginResponse { token: Str }

let SECRET = "demo-secret-change-me-in-prod"
let ADA_HASH = hash.password("secret-ada-123")

@auth_provider
fn check_token(headers: Map&amp;lt;Str, Str&amp;gt;) -&amp;gt; Result&amp;lt;User&amp;gt; {
    let auth: Str = match headers.get("authorization") {
        Ok(v) =&amp;gt; v,
        Err(_) =&amp;gt; return Err("missing Authorization header"),
    }
    let parts = auth.split(" ")
    if (parts.len() != 2 or parts[0] != "Bearer") {
        return Err("expected 'Bearer &amp;lt;token&amp;gt;'")
    }
    let claims = jwt.decode(parts[1], SECRET)?
    return find_user(claims["email"])
}

@post("/login")
fn login(creds: Credentials) -&amp;gt; LoginResponse {
    let user: User = match find_user(creds.email) {
        Ok(u) =&amp;gt; u,
        Err(_) =&amp;gt; return 401 { "error": "invalid credentials" },
    }
    if (not hash.verify(creds.password, ADA_HASH)) {
        return 401 { "error": "invalid credentials" }
    }
    let claims = { "email": user.email, "role": user.role }
    return LoginResponse { token: jwt.encode(claims, SECRET) }
}

@authenticated
@get("/me")
fn me(user: User) -&amp;gt; User =&amp;gt; user

@admin
@get("/admin/users")
fn admin_list(user: User) -&amp;gt; List&amp;lt;User&amp;gt; { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this code does, &lt;strong&gt;without a single &lt;code&gt;import&lt;/code&gt; or external dependency&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Starts an HTTP server on port 43928.&lt;/li&gt;
&lt;li&gt;Auto-generates OpenAPI 3.1 at &lt;code&gt;/openapi.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Auto-serves Scalar UI at &lt;code&gt;/docs&lt;/code&gt; with a working "Authorize" button.&lt;/li&gt;
&lt;li&gt;Signs and verifies JWT tokens (HS256/384/512 supported).&lt;/li&gt;
&lt;li&gt;Hashes passwords with &lt;strong&gt;Argon2id&lt;/strong&gt; (OWASP recommendation, not bcrypt).&lt;/li&gt;
&lt;li&gt;Statically validates that every &lt;code&gt;@authenticated&lt;/code&gt;/&lt;code&gt;@admin&lt;/code&gt; handler has an &lt;code&gt;@auth_provider&lt;/code&gt; declared, that the provider returns the right &lt;code&gt;User&lt;/code&gt; type, and that &lt;code&gt;@admin&lt;/code&gt; handlers have a &lt;code&gt;role: Str&lt;/code&gt; field on the &lt;code&gt;User&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Compiles to a single native binary with &lt;code&gt;fitz build&lt;/code&gt;, with bit-for-bit parity against &lt;code&gt;fitz run&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The auth, the hashing, the JWT, the OpenAPI with &lt;code&gt;bearerAuth&lt;/code&gt; security scheme, the 401/403 responses — all of that is in the binary &lt;code&gt;fitz&lt;/code&gt; itself. There's no &lt;code&gt;requirements.txt&lt;/code&gt;, no &lt;code&gt;package.json&lt;/code&gt;, no &lt;code&gt;Cargo.toml&lt;/code&gt; for the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "first-class" matters
&lt;/h2&gt;

&lt;p&gt;"First-class citizen" is one of those phrases that gets thrown around. Here's what I mean concretely.&lt;/p&gt;

&lt;p&gt;In FastAPI, &lt;code&gt;@app.get("/users")&lt;/code&gt; is a method on an object instance. The framework is a library you opt into. The router is a Python data structure. Authentication is a &lt;code&gt;Depends(...)&lt;/code&gt;. None of those things are visible to the type checker as anything special — they're just function calls and decorators that happen to produce metadata.&lt;/p&gt;

&lt;p&gt;In Fitz, &lt;code&gt;@get("/users")&lt;/code&gt; is a &lt;strong&gt;decorator the compiler understands&lt;/strong&gt;. The checker validates the path template, the parameter types against the path params, the body type, the return type. The OpenAPI generator inspects the AST directly — it doesn't introspect runtime objects, it doesn't need decorators that "register" themselves. The &lt;code&gt;User&lt;/code&gt; you return in your handler is the same &lt;code&gt;User&lt;/code&gt; that appears in the generated schema and in the Scalar UI.&lt;/p&gt;

&lt;p&gt;This sounds like a small distinction until you live it for a week. Then you stop fighting "why does Pydantic disagree with SQLAlchemy about whether this field is optional" and you start writing endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pieces
&lt;/h2&gt;

&lt;h3&gt;
  
  
  HTTP + OpenAPI + Scalar UI, all auto
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type Post { id: Int, title: Str, body: Str, tags: List&amp;lt;Str&amp;gt; }

@get("/posts")
fn list_posts() -&amp;gt; List&amp;lt;Post&amp;gt; { ... }

@post("/posts")
fn create_post(post: Post) -&amp;gt; Post { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's all you need. &lt;code&gt;/openapi.json&lt;/code&gt; and &lt;code&gt;/docs&lt;/code&gt; (Scalar UI) appear automatically. Path params (&lt;code&gt;/posts/{id}&lt;/code&gt;) are typed and coerced. JSON body deserialization checks for missing required fields, applies defaults, validates nullables, rejects extras. You can opt out with &lt;code&gt;@server(docs=false)&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  WebSockets, typed, with AsyncAPI auto-generated
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type ChatMessage { from: Str, text: Str }

@server(43929, ws_heartbeat_secs=30)
fn main() =&amp;gt; 0

@authenticated
@ws("/chat")
async fn chat(conn: WsConn&amp;lt;ChatMessage&amp;gt;, user: User) {
    loop {
        let msg = match conn.recv() {
            Ok(m) =&amp;gt; m,
            Err(_) =&amp;gt; break,
        }
        conn.broadcast(ChatMessage { from: user.name, text: msg.text })
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every frame is auto-marshalled to and from the declared type. Auth runs &lt;strong&gt;before&lt;/strong&gt; the WebSocket upgrade — invalid token gets a 401 without ever opening the socket. Ping/pong heartbeat keeps the connection alive past Nginx's 60s default. &lt;code&gt;/asyncapi.json&lt;/code&gt; is generated automatically (the event-driven sibling of OpenAPI). I don't know of another language that auto-generates AsyncAPI from typed source.&lt;/p&gt;

&lt;h3&gt;
  
  
  Background jobs and cron, no Redis required
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@cron("*/5 * * * *")
async fn cleanup_old_sessions() {
    db.exec("DELETE FROM sessions WHERE expires_at &amp;lt; now()")
}

@background
async fn send_welcome_email(email: Str) {
    // expensive thing
}

@post("/signup")
fn signup(creds: Credentials) -&amp;gt; User {
    let user = create_user(creds)
    spawn(send_welcome_email(user.email))  // fire-and-forget, typed Future&amp;lt;Null&amp;gt;
    return user
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Celery. No Redis. No &lt;code&gt;celery worker -A app&lt;/code&gt; next to your &lt;code&gt;uvicorn&lt;/code&gt; process. The scheduler is in your binary. Suitable for 90% of services — when you outgrow it, you outgrow it for a reason, and that's a Fase 11+ problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  A native ORM with a pure-Rust Postgres driver
&lt;/h3&gt;

&lt;p&gt;This is the piece I'm most proud of, and the one that took the longest. Fitz has its own Postgres driver written in Rust — no &lt;code&gt;libpq&lt;/code&gt;, no &lt;code&gt;tokio-postgres&lt;/code&gt;, no &lt;code&gt;sqlx&lt;/code&gt;. The wire protocol (v3.0), SCRAM-SHA-256 auth, prepared statements, the binary format for 11 OID types — all implemented from the RFC.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@table("users")
type User {
    @primary id: Int,
    email: Str,
    name: Str,
    @has_many("Post", "user_id") posts: List&amp;lt;Post&amp;gt;,
}

@table("posts")
type Post {
    @primary id: Int,
    user_id: Int,
    title: Str,
    body: Str,
    @belongs_to user: User?,
}

@get("/users")
async fn list_users(db: DbConn) -&amp;gt; List&amp;lt;User&amp;gt; {
    return User.all(db).preload("posts").await
}

@get("/users/{id}")
async fn get_user(db: DbConn, id: Int) -&amp;gt; Result&amp;lt;User&amp;gt; {
    return User.where(fn(u) =&amp;gt; u.id == id).first(db).await
}

@post("/users")
async fn create_user(db: DbConn, user: User) -&amp;gt; User {
    return User.insert(db, user).await
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The closure inside &lt;code&gt;.where(...)&lt;/code&gt; is &lt;strong&gt;translated to parametrized SQL at compile time&lt;/strong&gt; — &lt;code&gt;fn(u) =&amp;gt; u.id == id&lt;/code&gt; becomes &lt;code&gt;WHERE id = $1&lt;/code&gt;. Operators like &lt;code&gt;.is_in([...])&lt;/code&gt;, &lt;code&gt;.like(...)&lt;/code&gt;, &lt;code&gt;.ilike(...)&lt;/code&gt;, &lt;code&gt;.contains(...)&lt;/code&gt;, plus JSONB operators like &lt;code&gt;.has_key(...)&lt;/code&gt;, &lt;code&gt;.contains_json(...)&lt;/code&gt; all map to native Postgres operators. Eager loading with &lt;code&gt;.preload("posts")&lt;/code&gt; issues a single batched query. Aggregates (&lt;code&gt;.sum&lt;/code&gt;/&lt;code&gt;.avg&lt;/code&gt;/&lt;code&gt;.min&lt;/code&gt;/&lt;code&gt;.max&lt;/code&gt;/&lt;code&gt;.count&lt;/code&gt;) and &lt;code&gt;GROUP BY&lt;/code&gt; are supported through a separate &lt;code&gt;Aggregated&amp;lt;Row&amp;gt;&lt;/code&gt; type.&lt;/p&gt;

&lt;p&gt;This compiles to native code via &lt;code&gt;fitz build&lt;/code&gt;. The generated binary makes the same Postgres calls. Zero overhead at runtime for the SQL — it's already constant by the time the binary runs, comparable in performance to Diesel or sqlx.&lt;/p&gt;

&lt;h4&gt;
  
  
  How fast is it really? — head-to-head against SQLAlchemy
&lt;/h4&gt;

&lt;p&gt;The "zero overhead" claim is easy to make and easy to fake, so the repo ships a &lt;strong&gt;reproducible bench&lt;/strong&gt; between two equivalent boilerplates (&lt;a href="https://github.com/Thegreekman76/fitz/tree/main/boilerplates/api-postgres-fitz" rel="noopener noreferrer"&gt;&lt;code&gt;api-postgres-fitz&lt;/code&gt;&lt;/a&gt; vs &lt;a href="https://github.com/Thegreekman76/fitz/tree/main/boilerplates/api-postgres-python" rel="noopener noreferrer"&gt;&lt;code&gt;api-postgres-python&lt;/code&gt;&lt;/a&gt;) — same Postgres, same endpoints, same response shape, same &lt;code&gt;docker compose&lt;/code&gt;. Headline numbers on &lt;strong&gt;v0.10.13&lt;/strong&gt; (Intel Core Ultra 7 155H, Docker 29.2.1, 30s sustained, concurrency 10):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Fitz ORM&lt;/th&gt;
&lt;th&gt;Python + SQLAlchemy&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Memory peak&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.2 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;51 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.5× leaner&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /users&lt;/code&gt; p50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.88 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;37.85 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.76×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /users&lt;/code&gt; RPS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1944&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;246&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.91×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /users/{id}&lt;/code&gt; p50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.60 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;31.87 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.85×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET /users/{id}&lt;/code&gt; RPS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2604&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;296&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.80×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold start&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.14 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.22 s&lt;/td&gt;
&lt;td&gt;1.57×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image size&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;131 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;258 MB&lt;/td&gt;
&lt;td&gt;2× lighter&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's ~8× the throughput at ~5× less memory, on the same machine, in the same Docker network, against the same Postgres. Reproduce with &lt;a href="https://github.com/Thegreekman76/fitz/tree/main/benchmarks/orm-vs-sqlalchemy" rel="noopener noreferrer"&gt;&lt;code&gt;bash benchmarks/orm-vs-sqlalchemy/run.sh&lt;/code&gt;&lt;/a&gt; (~5–8 min with hot Docker cache; needs &lt;code&gt;oha&lt;/code&gt; + &lt;code&gt;jq&lt;/code&gt;). Full methodology, raw output, and the parts where the comparison is *un*fair to Fitz live in the &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/benchmarks/orm-vs-sqlalchemy/README.md" rel="noopener noreferrer"&gt;bench README&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Python interop when you do need it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from python import math, json

let radius = 5.0
let area: Float = math.pi * radius * radius

let parsed: Result&amp;lt;Map&amp;lt;Str, Any&amp;gt;&amp;gt; = match json.loads("{\"name\": \"ada\"}") {
    Ok(d) =&amp;gt; Ok(d),
    Err(e) =&amp;gt; Err("malformed JSON: {e}"),
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQLAlchemy, NumPy, pandas, anything on PyPI — accessible from Fitz with &lt;code&gt;from python import ...&lt;/code&gt;. The runtime embeds CPython via PyO3. Python exceptions become &lt;code&gt;Result::Err&lt;/code&gt; automatically. Async Python (&lt;code&gt;asyncpg&lt;/code&gt;, SQLAlchemy 2.x async) bridges to Fitz's &lt;code&gt;.await&lt;/code&gt; transparently. You can even do &lt;code&gt;fitz build --bundle-python&lt;/code&gt; to ship a binary with CPython embedded — no Python required on the destination machine.&lt;/p&gt;

&lt;p&gt;This is intentional. Fitz isn't trying to replace Python's ecosystem — it's trying to give you a better language for the web layer while keeping the door open to everything Python has already built.&lt;/p&gt;

&lt;h3&gt;
  
  
  Async, finally without color
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async fn fetch_user(id: Int) -&amp;gt; Result&amp;lt;User&amp;gt; { ... }

async fn main() {
    let user = fetch_user(42).await?
    print("got {user.name}")
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;async&lt;/code&gt;/&lt;code&gt;await&lt;/code&gt; is in the core, on a tokio runtime. The &lt;code&gt;?&lt;/code&gt; operator works through &lt;code&gt;Result&amp;lt;T&amp;gt;&lt;/code&gt;. The type checker enforces that &lt;code&gt;?&lt;/code&gt; only appears inside functions that return &lt;code&gt;Result&amp;lt;...&amp;gt;&lt;/code&gt;. Compiles to &lt;code&gt;async fn&lt;/code&gt; + &lt;code&gt;.await&lt;/code&gt; in Rust — same execution model as Rust async, same multi-threaded executor.&lt;/p&gt;

&lt;h3&gt;
  
  
  CLI builder — same language, command-line tools
&lt;/h3&gt;

&lt;p&gt;Fitz isn't only for HTTP services. The same compiler ships a built-in CLI builder, no library needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@command("greet", desc="Greet a person")
fn greet(name: Str, loud: Bool = false, count: Int = 1) -&amp;gt; Int {
    let n = count
    while n &amp;gt; 0 {
        if loud { print("HELLO, {name}!") } else { print("hello, {name}") }
        n = n - 1
    }
    return 0
}

@command("add", desc="Sum two numbers")
fn add(a: Int, b: Int) -&amp;gt; Int {
    print("{a + b}")
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin greet Ada &lt;span class="nt"&gt;--loud&lt;/span&gt; &lt;span class="nt"&gt;--count&lt;/span&gt; 3
HELLO, Ada!
HELLO, Ada!
HELLO, Ada!

&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin &lt;span class="nt"&gt;--help&lt;/span&gt;
USAGE: mybin &amp;lt;&lt;span class="nb"&gt;command&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;ARGS] &lt;span class="o"&gt;[&lt;/span&gt;OPTIONS]
COMMANDS:
    greet    Greet a person
    add      Sum two numbers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Convention over decoration: params without defaults are positional args, params with defaults are flags. Bool with &lt;code&gt;default = false&lt;/code&gt; becomes &lt;code&gt;--flag&lt;/code&gt;, other types become &lt;code&gt;--flag &amp;lt;value&amp;gt;&lt;/code&gt;. Short flags auto-derive (&lt;code&gt;--loud&lt;/code&gt; → &lt;code&gt;-l&lt;/code&gt;) with conflict detection. Help auto-generated, exit codes POSIX standard. &lt;strong&gt;Bit-for-bit parity&lt;/strong&gt; between &lt;code&gt;fitz run&lt;/code&gt; (development) and &lt;code&gt;fitz build&lt;/code&gt; (a self-contained binary you can drop into &lt;code&gt;/usr/local/bin&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;This is the same language. Same type checker. Same async/await. Same &lt;code&gt;Result&amp;lt;T&amp;gt;&lt;/code&gt; for errors. If your tool needs to hit the database, the ORM is there. If it needs HTTP, &lt;code&gt;@get&lt;/code&gt;/&lt;code&gt;@post&lt;/code&gt; are there. The line between "web service" and "CLI tool" stops being a stack decision.&lt;/p&gt;

&lt;h3&gt;
  
  
  Production-ready stack — from repo to production
&lt;/h3&gt;

&lt;p&gt;This is what separates Fitz from "interesting prototype" languages. Real services need health checks, secrets, observability, and a way to ship. All of them are part of the language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(43928)
fn main() =&amp;gt; 0

// Auto-mounted at GET /healthz and /readyz — Kubernetes-friendly.
@healthz
fn liveness() -&amp;gt; Bool =&amp;gt; true

@readyz
async fn readiness(db: DbConn) -&amp;gt; Bool {
    return match db.exec("SELECT 1").await {
        Ok(_) =&amp;gt; true,
        Err(_) =&amp;gt; false,
    }
}

// Secret&amp;lt;T&amp;gt; never leaks to logs, prints "***" on Display.
let db_url: Secret&amp;lt;Str&amp;gt; = secret("DATABASE_URL")
let log_level: Str = config("LOG_LEVEL", "info")

// Tracing + metrics with one decorator each.
@trace(name="process_order")
@metric(name="orders")
async fn process(order: Order) -&amp;gt; Result&amp;lt;Receipt&amp;gt; {
    // process_order_duration_seconds (histogram) and orders_calls_total
    // (counter) populate automatically on drop.
}

// Feature flags with two sources: fitz.toml [flags] + FITZ_FLAG_&amp;lt;NAME&amp;gt; env vars.
@flag("new-checkout")
@post("/v2/checkout")
fn v2_checkout(body: Cart) -&amp;gt; Receipt { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behind the scenes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HTTP access logs&lt;/strong&gt; auto-emit with &lt;code&gt;trace_id&lt;/code&gt;/&lt;code&gt;span_id&lt;/code&gt; propagated to every &lt;code&gt;log.info(...)&lt;/code&gt; inside the handler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenTelemetry OTLP&lt;/strong&gt; export with one env var: &lt;code&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/code&gt;. Spans flow to Jaeger/Tempo/Honeycomb. Without the env var, zero overhead, zero network calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus &lt;code&gt;/metrics&lt;/code&gt;&lt;/strong&gt; endpoint exposes counters and histograms — &lt;code&gt;@server(prometheus=true)&lt;/code&gt; enables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@flag&lt;/code&gt; on HTTP/WS handlers&lt;/strong&gt; returns 404 when the flag is off — gate the hot path before middleware/auth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deploying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Generate the Dockerfile + docker-compose.yml from the program shape.&lt;/span&gt;
fitz docker init

&lt;span class="c"&gt;# Build the binary, the Docker image, push to a registry.&lt;/span&gt;
fitz deploy docker &lt;span class="nt"&gt;--tag&lt;/span&gt; mycorp/api:v1

&lt;span class="c"&gt;# Or bring up locally with compose.&lt;/span&gt;
fitz deploy compose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fitz docker init&lt;/code&gt; reads your AST. If there's a &lt;code&gt;db.connect(...)&lt;/code&gt;, it adds Postgres to the compose. If there's &lt;code&gt;@server(N)&lt;/code&gt;, it sets &lt;code&gt;EXPOSE N&lt;/code&gt;. If there's &lt;code&gt;@cron&lt;/code&gt;, it adds &lt;code&gt;restart: unless-stopped&lt;/code&gt;. If there's &lt;code&gt;from python import ...&lt;/code&gt;, it picks &lt;code&gt;python:3.12-slim-bookworm&lt;/code&gt; instead of distroless. &lt;strong&gt;It generates what you'd write by hand&lt;/strong&gt;, you commit it, edit when you need to.&lt;/p&gt;

&lt;p&gt;I'm not aware of another language where deployment is a language feature. It is here because every project I shipped in Python ended with two days of debugging Dockerfile gotchas.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the tooling like?
&lt;/h2&gt;

&lt;p&gt;This is the part I underestimated when I started. A language without good tools is dead on arrival. Here's the current state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz run&lt;/code&gt;&lt;/strong&gt; — interpret the file directly. Fastest feedback loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz build&lt;/code&gt;&lt;/strong&gt; — compile to a native binary via a generated Rust project. Bit-for-bit parity with &lt;code&gt;fitz run&lt;/code&gt; is a hard requirement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz check&lt;/code&gt;&lt;/strong&gt; — type checker only, no execution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz test&lt;/code&gt;&lt;/strong&gt; — built-in test runner with &lt;code&gt;@test&lt;/code&gt; decorator and &lt;code&gt;assert&lt;/code&gt;, &lt;code&gt;assert_eq&lt;/code&gt;, &lt;code&gt;assert_throws&lt;/code&gt;. Cargo-style output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz dev&lt;/code&gt;&lt;/strong&gt; — hot reload. Watches &lt;code&gt;*.fitz&lt;/code&gt; and &lt;code&gt;fitz.toml&lt;/code&gt;, kills and respawns the child on change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz fmt&lt;/code&gt;&lt;/strong&gt; — opinionated formatter, zero config. Preserves your comments and blank lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz lint&lt;/code&gt;&lt;/strong&gt; — 4 built-in lints with &lt;code&gt;// @allow(&amp;lt;name&amp;gt;)&lt;/code&gt; suppression. Cargo-clippy-style output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz repl&lt;/code&gt;&lt;/strong&gt; — interactive REPL with multi-line support, &lt;code&gt;:type&lt;/code&gt;, &lt;code&gt;:load&lt;/code&gt;, persistent history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz openapi&lt;/code&gt;&lt;/strong&gt; — emit the OpenAPI schema without running the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz db diff&lt;/code&gt;/&lt;code&gt;migrate&lt;/code&gt;&lt;/strong&gt; — schema migration tooling. Diff the live DB against the &lt;code&gt;@table&lt;/code&gt; types in your code, generate idempotent migrations, apply them with &lt;code&gt;fitz db migrate&lt;/code&gt;. Same model as Alembic but with the types as source of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz docker init&lt;/code&gt;/&lt;code&gt;build&lt;/code&gt;&lt;/strong&gt; — generate the Dockerfile + &lt;code&gt;.dockerignore&lt;/code&gt; + &lt;code&gt;docker-compose.yml&lt;/code&gt; from the program shape, then &lt;code&gt;docker build&lt;/code&gt; wrapped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz deploy docker&lt;/code&gt;/&lt;code&gt;compose&lt;/code&gt;&lt;/strong&gt; — thin wrapper to ship the image or bring up locally with one command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VSCode extension&lt;/strong&gt; — diagnostics + hover + go-to-definition + autocomplete + &lt;strong&gt;signature help&lt;/strong&gt; + &lt;strong&gt;format on save&lt;/strong&gt; + &lt;strong&gt;bidirectional type inference&lt;/strong&gt; for callbacks, multi-platform distribution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz new&lt;/code&gt;&lt;/strong&gt; + &lt;strong&gt;&lt;code&gt;fitz add&lt;/code&gt;&lt;/strong&gt; + &lt;strong&gt;&lt;code&gt;fitz remove&lt;/code&gt;&lt;/strong&gt; + &lt;strong&gt;&lt;code&gt;fitz update&lt;/code&gt;&lt;/strong&gt; — package manager with &lt;code&gt;fitz.toml&lt;/code&gt;, lockfile, path deps, git deps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The LSP is real (&lt;code&gt;tower-lsp&lt;/code&gt; under the hood). The formatter is real (your code round-trips through it). The test runner is real. The whole thing is dogfooded — I write Fitz code with the same VSCode extension I ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Being honest about state
&lt;/h2&gt;

&lt;p&gt;This is a one-developer project. I started learning Rust to build it. I'm not going to pretend it's production-ready for everyone — here's what's true today (June 2026, release v0.15.0):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What works end-to-end, with bit-for-bit &lt;code&gt;fitz run&lt;/code&gt; ↔ &lt;code&gt;fitz build&lt;/code&gt; parity:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP server with &lt;code&gt;@get&lt;/code&gt;/&lt;code&gt;@post&lt;/code&gt;/&lt;code&gt;@put&lt;/code&gt;/&lt;code&gt;@delete&lt;/code&gt;, OpenAPI auto, Scalar UI.&lt;/li&gt;
&lt;li&gt;Middleware chain with &lt;code&gt;@middleware(fn)&lt;/code&gt; + CORS built-in.&lt;/li&gt;
&lt;li&gt;JWT auth with &lt;code&gt;@auth_provider&lt;/code&gt;/&lt;code&gt;@authenticated&lt;/code&gt;/&lt;code&gt;@admin&lt;/code&gt; + &lt;code&gt;@requires("custom_role")&lt;/code&gt; for RBAC. Argon2id password hashing. Token blacklist over Postgres for logout/refresh.&lt;/li&gt;
&lt;li&gt;WebSockets with &lt;code&gt;WsConn&amp;lt;T&amp;gt;&lt;/code&gt;, AsyncAPI auto, heartbeat, auth pre-upgrade.&lt;/li&gt;
&lt;li&gt;Cron jobs with &lt;code&gt;@cron("expr")&lt;/code&gt; (with retry, timezone, persistence, catch-up), background jobs with &lt;code&gt;@background&lt;/code&gt; + &lt;code&gt;spawn(...)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Postgres ORM with &lt;code&gt;@table&lt;/code&gt;/&lt;code&gt;@primary&lt;/code&gt;/&lt;code&gt;@column&lt;/code&gt;/&lt;code&gt;@belongs_to&lt;/code&gt;/&lt;code&gt;@has_many&lt;/code&gt;, closure-to-SQL, eager loading, &lt;strong&gt;transactions&lt;/strong&gt; (&lt;code&gt;db.transaction(fn)&lt;/code&gt;), &lt;strong&gt;schema migrations&lt;/strong&gt; (&lt;code&gt;fitz db diff&lt;/code&gt;/&lt;code&gt;migrate&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;TLS strict for Postgres (&lt;code&gt;sslmode=require&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Async/await on tokio.&lt;/li&gt;
&lt;li&gt;Python interop with &lt;code&gt;from python import ...&lt;/code&gt;, including auto-bridging async.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLI builder&lt;/strong&gt; with &lt;code&gt;@command&lt;/code&gt; — same language for CLI tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production stack&lt;/strong&gt;: &lt;code&gt;@healthz&lt;/code&gt;/&lt;code&gt;@readyz&lt;/code&gt;, &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt;, &lt;code&gt;secret()&lt;/code&gt;/&lt;code&gt;config()&lt;/code&gt;, &lt;code&gt;@trace&lt;/code&gt;/&lt;code&gt;@metric&lt;/code&gt;, &lt;code&gt;@flag&lt;/code&gt;, OpenTelemetry OTLP export, Prometheus &lt;code&gt;/metrics&lt;/code&gt;, &lt;code&gt;fitz docker init/build&lt;/code&gt;, &lt;code&gt;fitz deploy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Package manager with path deps and git deps.&lt;/li&gt;
&lt;li&gt;Full tooling: LSP (with signature help, format on save, hover over params and bindings), fmt, test, dev, repl, lint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What's not in the box yet:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Frontend in &lt;code&gt;.fitz&lt;/code&gt; (single-file components, SSR). Roadmap (Fase 11) — the most ambitious bet of the project. Not started.&lt;/li&gt;
&lt;li&gt;A public package registry. Path deps and git deps work today; the registry is on hold until there's real demand.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fitz deploy&lt;/code&gt; targets beyond &lt;code&gt;docker&lt;/code&gt;/&lt;code&gt;compose&lt;/code&gt; (no &lt;code&gt;fly&lt;/code&gt;/&lt;code&gt;railway&lt;/code&gt;/&lt;code&gt;k8s&lt;/code&gt; wrapper yet — use the native CLIs).&lt;/li&gt;
&lt;li&gt;Interactive debugging in VSCode (Debug Adapter Protocol). Workarounds: &lt;code&gt;print&lt;/code&gt;, REPL &lt;code&gt;:type&lt;/code&gt;/&lt;code&gt;:env&lt;/code&gt;, LSP diagnostics. Tracked as V6 in the backlog.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What's stable&lt;/strong&gt;: ~3030 Rust unit tests + 13 LSP E2E + 360 compile E2E (smoke over every example in the guide) + ~140 more across other suites running in CI on every push. Clippy &lt;code&gt;-D warnings&lt;/code&gt; clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install on Linux / macOS / WSL&lt;/span&gt;
curl &lt;span class="nt"&gt;-sSf&lt;/span&gt; https://thegreekman76.github.io/fitz/install.sh | sh

&lt;span class="c"&gt;# Install on Windows (PowerShell)&lt;/span&gt;
irm https://thegreekman76.github.io/fitz/install.ps1 | iex

&lt;span class="c"&gt;# Or grab a release binary from GitHub&lt;/span&gt;
&lt;span class="c"&gt;# https://github.com/Thegreekman76/fitz/releases&lt;/span&gt;

&lt;span class="c"&gt;# Reopen the terminal so the PATH change takes effect, then:&lt;/span&gt;
fitz &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;VSCode extension&lt;/strong&gt; (recommended — syntax highlighting, hover with types, autocomplete, signature help, format on save): grab the &lt;code&gt;.vsix&lt;/code&gt; for your platform from the same &lt;a href="https://github.com/Thegreekman76/fitz/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt; (&lt;code&gt;fitz-lang-&amp;lt;platform&amp;gt;.vsix&lt;/code&gt;) and install it with &lt;code&gt;code --install-extension fitz-lang-&amp;lt;platform&amp;gt;.vsix --force&lt;/code&gt;. The Language Server is bundled inside — no separate install needed. Reload VSCode once.&lt;/p&gt;

&lt;p&gt;First server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz new my-api &lt;span class="nt"&gt;--http&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;my-api
fitz dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight boilerplates ship in the repo under &lt;code&gt;boilerplates/&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;api-simple&lt;/code&gt; — minimal HTTP API.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api-middleware-cors&lt;/code&gt; — middleware chain + CORS configuration.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api-postgres-fitz&lt;/code&gt; — ORM + Postgres, Dockerized.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api-postgres-python&lt;/code&gt; — Postgres via Python/SQLAlchemy interop.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api-websocket&lt;/code&gt; — typed WebSocket chat.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api-orm-full&lt;/code&gt; — the full showcase: auth + ORM + WebSockets + cron + jobs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api-fullstack-postgres&lt;/code&gt; — backend + minimal frontend in one binary.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cli-tool&lt;/code&gt; — CLI app with &lt;code&gt;@command&lt;/code&gt; (no HTTP).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one runs with &lt;code&gt;docker compose up&lt;/code&gt; or &lt;code&gt;fitz dev&lt;/code&gt;. The README has the full matrix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built this
&lt;/h2&gt;

&lt;p&gt;I live in El Chaltén, in Argentine Patagonia. The Fitz Roy is the granite tower that defines the skyline here. Borges wrote that we live in a country where the past is uncertain and only the future is real. I think that's true of programming languages too: the past is full of accumulated workarounds for missing language features, and the future is whatever you decide to build.&lt;/p&gt;

&lt;p&gt;I've spent ten years writing API code in Python. I love FastAPI. But every time I start a new project, the first three hours are spent gluing libraries together to do the same thing I did last week. At some point the question becomes: what would a language look like that started from this set of needs in 2026, instead of growing them as patches on a language designed for shell scripting in 1991?&lt;/p&gt;

&lt;p&gt;That's Fitz.&lt;/p&gt;

&lt;p&gt;It's not done. I'm one person. It will get there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Docs and course&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Guide&lt;/strong&gt; (34 chapters): &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz/guide/&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Roadmap&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/docs/roadmap.md" rel="noopener noreferrer"&gt;docs/roadmap.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CHANGELOG&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;CHANGELOG.md&lt;/a&gt; — every release with detail.&lt;br&gt;
&lt;strong&gt;Issues&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/issues" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/issues&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you try it, I want to hear what broke. Open an issue or a discussion on GitHub.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>rust</category>
      <category>programming</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Presentando Citai: el motor RAG que construí en 6 artículos — y que ahora podés probar gratis</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Tue, 28 Apr 2026 13:10:00 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/presentando-citai-el-motor-rag-que-construi-en-6-articulos-y-que-ahora-podes-probar-gratis-4dl0</link>
      <guid>https://dev.to/martin_palopoli/presentando-citai-el-motor-rag-que-construi-en-6-articulos-y-que-ahora-podes-probar-gratis-4dl0</guid>
      <description>&lt;p&gt;Durante los últimos 6 artículos compartí cómo construí cada pieza de un motor RAG de producción: búsqueda híbrida, cross-encoder reranking, streaming SSE, multi-tenancy, cache semántico y detección de idioma. Hoy presento el producto terminado: &lt;strong&gt;Citai&lt;/strong&gt; (cite + AI) — un motor de conocimiento con citación verificable que cualquiera puede probar gratis en &lt;a href="https://citai.ai" rel="noopener noreferrer"&gt;citai.ai&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  La serie completa
&lt;/h2&gt;

&lt;p&gt;Si llegás a este artículo primero, acá está todo lo que construí y documenté:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pipeline RAG&lt;/strong&gt; — búsqueda híbrida (pgvector + BM25), cross-encoder reranking, MMR diversity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy en producción&lt;/strong&gt; — Docker multi-stage, Nginx, DigitalOcean, zero-downtime deploys&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tenancy&lt;/strong&gt; — planes, cuotas atómicas, rate limiting con Redis, aislamiento de datos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming SSE&lt;/strong&gt; — desde el LLM hasta el navegador pasando por Nginx, protocolo custom de 10 eventos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache semántico + FAQ&lt;/strong&gt; — 3 capas de ahorro que reducen ~40% el costo de LLM&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detección de idioma&lt;/strong&gt; — heurística ES/EN/PT sin APIs externas, reglas de contenido&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Cada artículo tiene código real del proyecto. Nada inventado para el tutorial.&lt;/p&gt;




&lt;h2&gt;
  
  
  Qué es Citai
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Citai&lt;/strong&gt; = cite + AI. Un motor de conocimiento donde cada respuesta cita la fuente exacta (página, documento, párrafo).&lt;/p&gt;

&lt;p&gt;La idea nació de una frustración: los chatbots corporativos que dicen "según nuestros documentos..." pero nunca te muestran dónde. No podés verificar nada. No podés confiar.&lt;/p&gt;

&lt;p&gt;Citai resuelve eso: subís tus documentos (PDF, DOCX, TXT, o URLs), el sistema los procesa, y cuando alguien pregunta, la respuesta viene con la referencia exacta. Si dice "ver página 15 del manual", podés ir a la página 15 y verificar.&lt;/p&gt;




&lt;h2&gt;
  
  
  Para quién es
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Empresas con documentación interna&lt;/strong&gt;: manuales, políticas, procedimientos que nadie lee&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Equipos de soporte&lt;/strong&gt;: base de conocimiento que responde antes que el humano&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Educación&lt;/strong&gt;: material de estudio con respuestas verificables&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Salud y legal&lt;/strong&gt;: donde citar la fuente no es opcional, es obligatorio&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cualquier caso donde la respuesta &lt;em&gt;sin fuente&lt;/em&gt; no alcanza.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lo que incluye (y que ya mostré cómo funciona)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Búsqueda híbrida de 3 etapas
&lt;/h3&gt;

&lt;p&gt;Lo que describí en el artículo #1 es exactamente lo que corre en producción:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Query → Vector (pgvector) + BM25 (tsvector)
     → RRF merge (70/30)
     → Cross-encoder rerank
     → MMR diversity (λ=0.6)
     → Top 5-10 fuentes con citación exacta
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No es "cosine similarity y listo". Es un pipeline completo con reranking que mejora la relevancia un 25-40% sobre búsqueda vectorial pura.&lt;/p&gt;

&lt;h3&gt;
  
  
  Confianza calibrada
&lt;/h3&gt;

&lt;p&gt;Cada respuesta tiene un score de confianza (0-100%) basado en los scores del cross-encoder. Si la confianza es baja, el sistema lo dice. No inventa.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"No encontré información suficiente en la documentación disponible
para responder con certeza."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eso es más valioso que una respuesta inventada con tono seguro.&lt;/p&gt;

&lt;h3&gt;
  
  
  Widget embebible
&lt;/h3&gt;

&lt;p&gt;Una línea de código:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script
  &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://citai.ai/widget.js"&lt;/span&gt;
  &lt;span class="na"&gt;data-api-key=&lt;/span&gt;&lt;span class="s"&gt;"sk-..."&lt;/span&gt;
  &lt;span class="na"&gt;data-base-url=&lt;/span&gt;&lt;span class="s"&gt;"https://citai.ai"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shadow DOM (no contamina tus estilos), streaming SSE en tiempo real, formularios pre-chat, horario de atención, escalación a humanos, respuestas rápidas, y 4 templates por industria. Todo lo que describí en el artículo #4 sobre SSE es lo que corre en el widget.&lt;/p&gt;

&lt;h3&gt;
  
  
  RAG configurable sin código
&lt;/h3&gt;

&lt;p&gt;Cada base de conocimiento tiene su propia configuración:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parámetro&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Qué controla&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;candidate_k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;Candidatos iniciales por búsqueda&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rerank_top_n&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;Documentos después del reranker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;top_k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Fuentes finales (post-MMR)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lambda_param&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.6&lt;/td&gt;
&lt;td&gt;Balance relevancia vs diversidad&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bm25_weight&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;Peso de BM25 en la fusión&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;faq_threshold&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.85&lt;/td&gt;
&lt;td&gt;Umbral de match para FAQs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;temperature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;Creatividad del LLM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;chunk_size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;Tamaño de fragmentos&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Un admin puede ajustar todo esto desde la UI, sin tocar código. Y el Playground permite testear cambios antes de aplicarlos.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cache semántico + FAQ matching
&lt;/h3&gt;

&lt;p&gt;Lo del artículo #5 en acción:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FAQ match&lt;/strong&gt; → respuesta curada en ~15ms, costo $0&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache hit&lt;/strong&gt; (similitud ≥ 0.95) → respuesta cacheada en ~20ms, costo $0&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache miss&lt;/strong&gt; → pipeline completo en ~2-4s&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;En producción, entre FAQ y cache se evita el 30-45% de las llamadas al LLM. Eso es plata real ahorrada.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multilingüe sin APIs externas
&lt;/h3&gt;

&lt;p&gt;Detección automática de ES/EN/PT por heurística (artículo #6). El usuario pregunta en su idioma, Citai responde en ese idioma. Sin latencia extra, sin costo extra.&lt;/p&gt;

&lt;p&gt;Los embeddings son multilingües (&lt;code&gt;paraphrase-multilingual-MiniLM-L12-v2&lt;/code&gt;), así que podés tener documentos en español y preguntar en inglés — funciona.&lt;/p&gt;

&lt;h3&gt;
  
  
  Analytics completo
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Tasa de resolución (resueltas / escaladas / negativas)&lt;/li&gt;
&lt;li&gt;Top 10 preguntas frecuentes&lt;/li&gt;
&lt;li&gt;Lagunas de conocimiento (preguntas sin respuesta)&lt;/li&gt;
&lt;li&gt;Distribución por tags (auto-tagging con LLM)&lt;/li&gt;
&lt;li&gt;Costos por proveedor y ahorro por FAQ/cache&lt;/li&gt;
&lt;li&gt;Health Score por base de conocimiento (0-100)&lt;/li&gt;
&lt;li&gt;Exportación CSV/JSON&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Smart Routing
&lt;/h3&gt;

&lt;p&gt;Si tenés múltiples bases de conocimiento, Citai rutea automáticamente la pregunta a la KB más relevante usando centroides de embeddings. Sin configuración manual.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reglas de contenido
&lt;/h3&gt;

&lt;p&gt;BLOCK, REDIRECT y FILTER — para controlar qué preguntas se procesan y qué temas se bloquean. Evaluadas antes del LLM, sin costo.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tool Calling
&lt;/h3&gt;

&lt;p&gt;Conectá APIs externas (clima, calendario, CRM, lo que sea) y el agente las invoca automáticamente cuando la pregunta lo requiere. Templates incluidos para los casos más comunes.&lt;/p&gt;




&lt;h2&gt;
  
  
  El stack (para los que leyeron toda la serie)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────┐
│            Frontend (Vue 3 + TS)        │
│    Tailwind · vue-i18n · Chart.js       │
├─────────────────────────────────────────┤
│              Nginx (TLS + HTTP/2)       │
│    Proxy SSE · Gzip · Maintenance mode  │
├─────────────────────────────────────────┤
│          Backend (FastAPI async)         │
│   SQLAlchemy · Alembic · JWT · SSE      │
├────────────┬────────────┬───────────────┤
│ PostgreSQL │   Redis    │    Groq       │
│  pgvector  │ Rate limit │  LLaMA 3.3   │
│   BM25     │ Concurrent │  70B versatile│
└────────────┴────────────┴───────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LLM&lt;/strong&gt;: Groq como primario (rápido y barato), con fallback a GPT-4o-mini&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embeddings&lt;/strong&gt;: &lt;code&gt;paraphrase-multilingual-MiniLM-L12-v2&lt;/code&gt; (384 dimensiones, corre local)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reranker&lt;/strong&gt;: &lt;code&gt;cross-encoder/ms-marco-MiniLM-L-6-v2&lt;/code&gt; (también local)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DB&lt;/strong&gt;: PostgreSQL 16 + pgvector (HNSW) + tsvector (BM25)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infra&lt;/strong&gt;: Docker Compose en un VPS de 4GB ($24/mes)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sí, todo esto corre en un droplet de 4GB. El artículo #2 explica cómo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Planes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Free&lt;/th&gt;
&lt;th&gt;Starter&lt;/th&gt;
&lt;th&gt;Pro&lt;/th&gt;
&lt;th&gt;Enterprise&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Precio&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$29/mes&lt;/td&gt;
&lt;td&gt;$79/mes&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consultas IA/mes&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;Ilimitadas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Usuarios&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;Ilimitados&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bases de conocimiento&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;Ilimitadas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documentos&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;Ilimitados&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Almacenamiento&lt;/td&gt;
&lt;td&gt;100 MB&lt;/td&gt;
&lt;td&gt;500 MB&lt;/td&gt;
&lt;td&gt;5 GB&lt;/td&gt;
&lt;td&gt;50 GB+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API keys&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Ilimitadas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Widget&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Sí&lt;/td&gt;
&lt;td&gt;Sí&lt;/td&gt;
&lt;td&gt;White-label&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAQs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Ilimitadas&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Ilimitadas&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Ilimitadas&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Ilimitadas&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Las FAQs son ilimitadas en todos los planes. Si una pregunta matchea una FAQ curada, se responde gratis sin consumir consultas IA. Eso no es un truco — es el diseño del artículo #5 en acción.&lt;/p&gt;

&lt;p&gt;El plan Free no pide tarjeta de crédito. Registrate, subí un PDF, y preguntale algo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lo que aprendí construyendo esto
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. El reranker es lo que más mejora la calidad
&lt;/h3&gt;

&lt;p&gt;Puedo cambiar el LLM, los embeddings, el tamaño de chunks — nada mejora tanto la relevancia como agregar un cross-encoder después de la búsqueda. Es el upgrade más barato en relación costo/beneficio.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Las FAQs son aburridas pero poderosas
&lt;/h3&gt;

&lt;p&gt;Una tabla con preguntas y respuestas no es sexy. Pero cada FAQ es una respuesta perfecta, en 15ms, a costo cero, para siempre. En un sistema SaaS multi-tenant, eso escala mejor que cualquier LLM.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. El cache semántico necesita umbral alto
&lt;/h3&gt;

&lt;p&gt;0.95 de similitud mínima. A 0.90 tuve cache hits incorrectos. Mejor un miss que una respuesta equivocada.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Multi-tenancy no es "agregar un tenant_id"
&lt;/h3&gt;

&lt;p&gt;Es plan enforcement con contadores atómicos, rate limiting, aislamiento de datos en cada query, y un sistema de roles que funcione. Lleva más código que el pipeline RAG mismo.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. SSE streaming a través de Nginx tiene trampas
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;proxy_buffering off&lt;/code&gt; y &lt;code&gt;proxy_cache off&lt;/code&gt; son obvios. Pero &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; en el header de respuesta fue lo que realmente lo resolvió. Artículo #4 tiene los detalles.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Un VPS de 4GB alcanza para más de lo que pensás
&lt;/h3&gt;

&lt;p&gt;Con 1 worker de Uvicorn (el embedding model usa ~500MB), PostgreSQL tuneado y Redis con 64MB, el sistema sirve tráfico real sin problemas. No necesitás Kubernetes para lanzar.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. La confianza calibrada cambia la percepción del usuario
&lt;/h3&gt;

&lt;p&gt;Cuando el sistema dice "no estoy seguro" en vez de inventar, el usuario confía más en las respuestas donde sí está seguro. Contra-intuitivo pero medible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Probalo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://citai.ai" rel="noopener noreferrer"&gt;citai.ai&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Registrate gratis (sin tarjeta)&lt;/li&gt;
&lt;li&gt;Subí un PDF o documento&lt;/li&gt;
&lt;li&gt;Preguntale algo&lt;/li&gt;
&lt;li&gt;Verificá la fuente&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Si leíste alguno de los 6 artículos anteriores, todo lo que describí es lo que vas a encontrar adentro. No es un prototipo — es producción.&lt;/p&gt;




&lt;h2&gt;
  
  
  Qué sigue
&lt;/h2&gt;

&lt;p&gt;Estoy trabajando en:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Importación automática de URLs&lt;/strong&gt; con sync periódico&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Templates por industria&lt;/strong&gt; más completos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SDK para integración&lt;/strong&gt; programática&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Más proveedores LLM&lt;/strong&gt; (Mistral, Ollama local)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Si te interesa alguna de estas features, dejá un comentario o contactame. Si encontrás un bug, también — es software real, no una demo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gracias
&lt;/h2&gt;

&lt;p&gt;A todos los que leyeron, comentaron y reaccionaron a los artículos anteriores. Esta serie empezó como documentación personal y se convirtió en algo que vale la pena compartir.&lt;/p&gt;

&lt;p&gt;Si Citai te parece útil — o si simplemente te interesa el stack — un like ayuda a que llegue a más gente.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Esta es la última entrega de la serie "RAG en Producción". Los 6 artículos anteriores están en mi perfil. Preguntas, feedback o ideas → comentarios abiertos.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>showdev</category>
      <category>python</category>
    </item>
    <item>
      <title>Introducing Citai: the RAG engine I built across 6 articles — now free to try</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Tue, 28 Apr 2026 13:10:00 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/introducing-citai-the-rag-engine-i-built-across-6-articles-now-free-to-try-ki6</link>
      <guid>https://dev.to/martin_palopoli/introducing-citai-the-rag-engine-i-built-across-6-articles-now-free-to-try-ki6</guid>
      <description>&lt;p&gt;Over the last 6 articles I shared how I built every piece of a production RAG engine: hybrid search, cross-encoder reranking, SSE streaming, multi-tenancy, semantic cache and language detection. Today I'm introducing the finished product: &lt;strong&gt;Citai&lt;/strong&gt; (cite + AI) — a knowledge engine with verifiable citations that anyone can try for free at &lt;a href="https://citai.ai" rel="noopener noreferrer"&gt;citai.ai&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The full series
&lt;/h2&gt;

&lt;p&gt;If you're landing on this article first, here's everything I built and documented:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;RAG Pipeline&lt;/strong&gt; — hybrid search (pgvector + BM25), cross-encoder reranking, MMR diversity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production deploy&lt;/strong&gt; — Docker multi-stage, Nginx, DigitalOcean, zero-downtime deploys&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tenancy&lt;/strong&gt; — plans, atomic quotas, Redis rate limiting, data isolation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSE Streaming&lt;/strong&gt; — from the LLM to the browser through Nginx, custom 10-event protocol&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic cache + FAQ&lt;/strong&gt; — 3 savings layers that cut ~40% of LLM cost&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language detection&lt;/strong&gt; — ES/EN/PT heuristics without external APIs, content rules&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every article has real code from the project. Nothing made up for the tutorial.&lt;/p&gt;




&lt;h2&gt;
  
  
  What is Citai
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Citai&lt;/strong&gt; = cite + AI. A knowledge engine where every answer cites the exact source (page, document, paragraph).&lt;/p&gt;

&lt;p&gt;The idea came from a frustration: corporate chatbots that say "according to our documents..." but never show you where. You can't verify anything. You can't trust it.&lt;/p&gt;

&lt;p&gt;Citai solves that: upload your documents (PDF, DOCX, TXT, or URLs), the system processes them, and when someone asks a question, the answer comes with the exact reference. If it says "see page 15 of the manual", you can go to page 15 and verify.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who it's for
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Companies with internal documentation&lt;/strong&gt;: manuals, policies, procedures nobody reads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support teams&lt;/strong&gt;: a knowledge base that answers before the human does&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Education&lt;/strong&gt;: study materials with verifiable answers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Healthcare and legal&lt;/strong&gt;: where citing the source isn't optional — it's mandatory&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any use case where an answer &lt;em&gt;without a source&lt;/em&gt; isn't enough.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's included (and what I already showed how it works)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  3-stage hybrid search
&lt;/h3&gt;

&lt;p&gt;What I described in article #1 is exactly what runs in production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Query → Vector (pgvector) + BM25 (tsvector)
     → RRF merge (70/30)
     → Cross-encoder rerank
     → MMR diversity (λ=0.6)
     → Top 5-10 sources with exact citation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's not "cosine similarity and done". It's a full pipeline with reranking that improves relevance by 25-40% over pure vector search.&lt;/p&gt;

&lt;h3&gt;
  
  
  Calibrated confidence
&lt;/h3&gt;

&lt;p&gt;Every answer has a confidence score (0-100%) based on cross-encoder scores. If confidence is low, the system says so. It doesn't make things up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"I couldn't find enough information in the available documentation
to answer with certainty."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's more valuable than a fabricated answer delivered with confidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Embeddable widget
&lt;/h3&gt;

&lt;p&gt;One line of code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script
  &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://citai.ai/widget.js"&lt;/span&gt;
  &lt;span class="na"&gt;data-api-key=&lt;/span&gt;&lt;span class="s"&gt;"sk-..."&lt;/span&gt;
  &lt;span class="na"&gt;data-base-url=&lt;/span&gt;&lt;span class="s"&gt;"https://citai.ai"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shadow DOM (won't pollute your styles), real-time SSE streaming, pre-chat forms, business hours, human escalation, quick replies, and 4 industry templates. Everything I described in article #4 about SSE is what runs inside the widget.&lt;/p&gt;

&lt;h3&gt;
  
  
  No-code RAG configuration
&lt;/h3&gt;

&lt;p&gt;Each knowledge base has its own configuration:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What it controls&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;candidate_k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;Initial candidates per search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rerank_top_n&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;Documents after the reranker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;top_k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Final sources (post-MMR)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lambda_param&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.6&lt;/td&gt;
&lt;td&gt;Relevance vs diversity balance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bm25_weight&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;BM25 weight in the fusion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;faq_threshold&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.85&lt;/td&gt;
&lt;td&gt;FAQ match threshold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;temperature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;LLM creativity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;chunk_size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;Chunk size in characters&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An admin can adjust all of this from the UI, no code required. And the Playground lets you test changes before applying them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Semantic cache + FAQ matching
&lt;/h3&gt;

&lt;p&gt;Article #5 in action:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FAQ match&lt;/strong&gt; → curated answer in ~15ms, cost $0&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache hit&lt;/strong&gt; (similarity &amp;gt;= 0.95) → cached response in ~20ms, cost $0&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache miss&lt;/strong&gt; → full pipeline in ~2-4s&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In production, FAQ and cache combined avoid 30-45% of LLM calls. That's real money saved.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multilingual without external APIs
&lt;/h3&gt;

&lt;p&gt;Automatic ES/EN/PT detection via heuristics (article #6). Users ask in their language, Citai responds in that language. No extra latency, no extra cost.&lt;/p&gt;

&lt;p&gt;The embeddings are multilingual (&lt;code&gt;paraphrase-multilingual-MiniLM-L12-v2&lt;/code&gt;), so you can have documents in Spanish and ask in English — it works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Full analytics
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Resolution rate (resolved / escalated / negative)&lt;/li&gt;
&lt;li&gt;Top 10 most frequent questions&lt;/li&gt;
&lt;li&gt;Knowledge gaps (unanswered queries)&lt;/li&gt;
&lt;li&gt;Tag distribution (auto-tagging via LLM)&lt;/li&gt;
&lt;li&gt;Cost by provider and FAQ/cache savings&lt;/li&gt;
&lt;li&gt;Health Score per knowledge base (0-100)&lt;/li&gt;
&lt;li&gt;CSV/JSON export&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Smart Routing
&lt;/h3&gt;

&lt;p&gt;If you have multiple knowledge bases, Citai automatically routes the query to the most relevant KB using embedding centroids. No manual configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Content rules
&lt;/h3&gt;

&lt;p&gt;BLOCK, REDIRECT and FILTER — to control which questions get processed and which topics are blocked. Evaluated before the LLM, at zero cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tool Calling
&lt;/h3&gt;

&lt;p&gt;Connect external APIs (weather, calendar, CRM, anything) and the agent invokes them automatically when the question requires it. Templates included for the most common use cases.&lt;/p&gt;




&lt;h2&gt;
  
  
  The stack (for those who read the whole series)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────┐
│            Frontend (Vue 3 + TS)        │
│    Tailwind · vue-i18n · Chart.js       │
├─────────────────────────────────────────┤
│              Nginx (TLS + HTTP/2)       │
│    Proxy SSE · Gzip · Maintenance mode  │
├─────────────────────────────────────────┤
│          Backend (FastAPI async)         │
│   SQLAlchemy · Alembic · JWT · SSE      │
├────────────┬────────────┬───────────────┤
│ PostgreSQL │   Redis    │    Groq       │
│  pgvector  │ Rate limit │  LLaMA 3.3   │
│   BM25     │ Concurrent │  70B versatile│
└────────────┴────────────┴───────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LLM&lt;/strong&gt;: Groq as primary (fast and cheap), with GPT-4o-mini fallback&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embeddings&lt;/strong&gt;: &lt;code&gt;paraphrase-multilingual-MiniLM-L12-v2&lt;/code&gt; (384 dimensions, runs locally)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reranker&lt;/strong&gt;: &lt;code&gt;cross-encoder/ms-marco-MiniLM-L-6-v2&lt;/code&gt; (also local)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DB&lt;/strong&gt;: PostgreSQL 16 + pgvector (HNSW) + tsvector (BM25)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infra&lt;/strong&gt;: Docker Compose on a 4GB VPS ($24/month)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Yes, all of this runs on a 4GB droplet. Article #2 explains how.&lt;/p&gt;




&lt;h2&gt;
  
  
  Plans
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Free&lt;/th&gt;
&lt;th&gt;Starter&lt;/th&gt;
&lt;th&gt;Pro&lt;/th&gt;
&lt;th&gt;Enterprise&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$29/mo&lt;/td&gt;
&lt;td&gt;$79/mo&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI queries/month&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Users&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Knowledge bases&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documents&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;100 MB&lt;/td&gt;
&lt;td&gt;500 MB&lt;/td&gt;
&lt;td&gt;5 GB&lt;/td&gt;
&lt;td&gt;50 GB+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API keys&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Widget&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;White-label&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAQs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Unlimited&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Unlimited&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Unlimited&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Unlimited&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;FAQs are unlimited across all plans. If a question matches a curated FAQ, it's answered for free without consuming AI queries. That's not a gimmick — it's the design from article #5 in action.&lt;/p&gt;

&lt;p&gt;The Free plan doesn't require a credit card. Sign up, upload a PDF, and ask it something.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I learned building this
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The reranker improves quality more than anything else
&lt;/h3&gt;

&lt;p&gt;I can swap the LLM, the embeddings, the chunk size — nothing improves relevance as much as adding a cross-encoder after retrieval. It's the cheapest upgrade in terms of cost/benefit ratio.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. FAQs are boring but powerful
&lt;/h3&gt;

&lt;p&gt;A table with questions and answers isn't sexy. But each FAQ is a perfect answer, served in 15ms, at zero cost, forever. In a multi-tenant SaaS, that scales better than any LLM.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Semantic cache needs a high threshold
&lt;/h3&gt;

&lt;p&gt;0.95 minimum similarity. At 0.90 I got incorrect cache hits. A miss is better than a wrong answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Multi-tenancy is not "add a tenant_id"
&lt;/h3&gt;

&lt;p&gt;It's plan enforcement with atomic counters, rate limiting, data isolation in every query, and a role system that actually works. It takes more code than the RAG pipeline itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. SSE streaming through Nginx has gotchas
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;proxy_buffering off&lt;/code&gt; and &lt;code&gt;proxy_cache off&lt;/code&gt; are obvious. But &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; in the response header is what actually fixed it. Article #4 has the details.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. A 4GB VPS handles more than you'd think
&lt;/h3&gt;

&lt;p&gt;With 1 Uvicorn worker (the embedding model uses ~500MB), tuned PostgreSQL and Redis at 64MB, the system serves real traffic without issues. You don't need Kubernetes to launch.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Calibrated confidence changes user perception
&lt;/h3&gt;

&lt;p&gt;When the system says "I'm not sure" instead of making things up, users trust the answers where it &lt;em&gt;is&lt;/em&gt; sure even more. Counter-intuitive but measurable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://citai.ai" rel="noopener noreferrer"&gt;citai.ai&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sign up for free (no credit card)&lt;/li&gt;
&lt;li&gt;Upload a PDF or document&lt;/li&gt;
&lt;li&gt;Ask it something&lt;/li&gt;
&lt;li&gt;Verify the source&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've read any of the 6 previous articles, everything I described is what you'll find inside. It's not a prototype — it's production.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;I'm working on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automatic URL import&lt;/strong&gt; with periodic sync&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;More industry templates&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SDK for programmatic integration&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More LLM providers&lt;/strong&gt; (Mistral, local Ollama)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're interested in any of these features, drop a comment or reach out. If you find a bug, same thing — it's real software, not a demo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Thank you
&lt;/h2&gt;

&lt;p&gt;To everyone who read, commented, and reacted to the previous articles. This series started as personal documentation and became something worth sharing.&lt;/p&gt;

&lt;p&gt;If Citai looks useful to you — or if you're just interested in the stack — a like helps it reach more people.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is the final installment of the "RAG in Production" series. The 6 previous articles are on my profile. Questions, feedback or ideas → comments are open.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>showdev</category>
      <category>rag</category>
    </item>
    <item>
      <title>Language Detection Without External APIs for a Multilingual RAG System</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Tue, 21 Apr 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/language-detection-without-external-apis-for-a-multilingual-rag-system-2j3n</link>
      <guid>https://dev.to/martin_palopoli/language-detection-without-external-apis-for-a-multilingual-rag-system-2j3n</guid>
      <description>&lt;p&gt;I implemented complete linguistic intelligence for a multi-tenant RAG engine: heuristic language detection (ES/EN/PT) with zero latency, configurable priority chain, injection into LLM prompts, content rules (BLOCK/REDIRECT/FILTER) for moderation, and &lt;code&gt;browser_lang&lt;/code&gt; from the widget. All without external APIs, in ~90 lines of code.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Multilingual Problem in RAG
&lt;/h2&gt;

&lt;p&gt;Most RAG tutorials assume a single language. In production you'll run into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Queries in English against documents in Spanish (or vice versa)&lt;/li&gt;
&lt;li&gt;Widgets embedded on Brazilian sites receiving Portuguese&lt;/li&gt;
&lt;li&gt;The LLM responds in the context's language instead of the user's language&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Common "solutions" are detection APIs (Google, AWS Comprehend) that add latency and cost, libraries like langdetect/fasttext that are heavy dependencies for 3 languages, or simply ignoring the problem.&lt;/p&gt;

&lt;p&gt;I needed something with &lt;strong&gt;zero latency&lt;/strong&gt;, no dependencies, and &lt;strong&gt;configurable per Knowledge Base&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Solution: Heuristic Detection + Priority Chain
&lt;/h2&gt;

&lt;h3&gt;
  
  
  General Approach
&lt;/h3&gt;

&lt;p&gt;Instead of using a full statistical model, I attack the problem in layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User query
       │
       ▼
┌──────────────────────┐
│  1. Fixed override?   │──→ If admin forced "en": always use "en"
└──────┬───────────────┘
       │ No override
       ▼
┌──────────────────────┐
│  2. Heuristic         │──→ Count ES/EN/PT keywords
└──────┬───────────────┘
       │ Score &amp;lt; threshold
       ▼
┌──────────────────────┐
│  3. Browser lang?     │──→ Browser language (widget)
└──────┬───────────────┘
       │ Not available
       ▼
    Default: "es"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Word Lists
&lt;/h3&gt;

&lt;p&gt;The heart of detection is three sets of common words per language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;

&lt;span class="n"&gt;_PUNCT_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^\w\s]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UNICODE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;_EN_WORDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;how&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;what&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;where&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;when&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;why&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;who&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;which&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;can&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;could&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;would&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;should&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;does&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;do&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;are&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;this&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;that&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;help&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;please&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tell&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;explain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;show&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;need&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;want&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;have&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;about&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;they&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;there&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;their&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;been&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;just&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;also&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;very&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;some&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;any&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;other&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;than&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;into&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;_PT_WORDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;como&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;onde&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quando&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;porque&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quem&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;qual&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poderia&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;esta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;são&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;isso&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;isto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ajuda&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ajudar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mostre&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;explique&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;você&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vocês&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;obrigado&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;obrigada&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preciso&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quero&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenho&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sobre&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;para&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sua&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;também&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;muito&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;algum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mais&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ainda&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aqui&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;depois&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;antes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entre&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;posso&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gostaria&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fazer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dizer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;favor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bom&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;boa&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dia&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;noite&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;olá&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sim&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;não&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bem&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tudo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;_ES_WORDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;qué&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cómo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dónde&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cuándo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cuál&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quién&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;puedo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;podría&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;necesito&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiero&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tengo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;también&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aquí&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;después&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ahora&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entonces&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pero&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sino&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aunque&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;desde&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hasta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hacia&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;según&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ayuda&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;explicar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mostrar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;buscar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hola&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gracias&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;información&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pregunta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;respuesta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;por&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;favor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key decisions in the lists:&lt;/strong&gt; English focuses on function words (&lt;code&gt;the&lt;/code&gt;, &lt;code&gt;is&lt;/code&gt;, &lt;code&gt;are&lt;/code&gt;) that almost never appear in Spanish/Portuguese. Portuguese includes key differentiators vs ES: &lt;code&gt;você&lt;/code&gt;, &lt;code&gt;não&lt;/code&gt;, &lt;code&gt;obrigado&lt;/code&gt;, &lt;code&gt;tudo&lt;/code&gt;. Spanish uses accents when possible (&lt;code&gt;qué&lt;/code&gt;, &lt;code&gt;cómo&lt;/code&gt;, &lt;code&gt;dónde&lt;/code&gt;) — a user who types with accents is almost certainly writing in Spanish.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Detection Function
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;detect_language&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Detect query language. Returns &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, or &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;. Default &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_PUNCT_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;_EN_WORDS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;_PT_WORDS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;_ES_WORDS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why threshold of 2?&lt;/strong&gt; With a single matching word, false positive risk is high. "como" exists in both Spanish and Portuguese. "para" too. But if we find 2+ words from one language, the probability of being correct jumps dramatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why default "es"?&lt;/strong&gt; My primary use case is Latin America. If I can't detect with confidence, Spanish is the safest bet. This is configurable — you can change the default based on your market.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Priority Chain: &lt;code&gt;resolve_language()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Heuristic detection is just one layer. The real function that decides the language is &lt;code&gt;resolve_language()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_LANG_INSTRUCTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Respond entirely in English.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Responda inteiramente em português.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Responde completamente en español.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_language&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;auto_detect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;override&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;browser_lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Resolve the final language for a query.
    Priority: override &amp;gt; heuristic detection &amp;gt; browser fallback &amp;gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. Admin override: ignore everything, force language
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;override&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;override&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;override&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Heuristic: analyze the text
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;auto_detect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;detected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;detect_language&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# If detected something other than default, trust it
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;detected&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;browser_lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;detected&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. Browser lang: user's browser language
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;browser_lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;bl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser_lang&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()[:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bl&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;bl&lt;/span&gt;

    &lt;span class="c1"&gt;# 4. Default
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why This Priority?
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;When it wins&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Admin override&lt;/td&gt;
&lt;td&gt;Always. If the admin says "this KB is in English", it's respected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Heuristic&lt;/td&gt;
&lt;td&gt;If it detects EN or PT with confidence (score &amp;gt;= 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Browser lang&lt;/td&gt;
&lt;td&gt;If heuristic couldn't decide (returned "es" by default)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Default "es"&lt;/td&gt;
&lt;td&gt;Last resort&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The subtle trick&lt;/strong&gt;: if the heuristic returns "es", it could be actual Spanish OR "couldn't detect". In that case, we give &lt;code&gt;browser_lang&lt;/code&gt; a chance. If the user's browser is in Portuguese and the query is ambiguous, it's probably Portuguese.&lt;/p&gt;

&lt;h3&gt;
  
  
  Usage in Chat vs Widget
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Chat: no browser_lang
&lt;/span&gt;&lt;span class="n"&gt;detected_lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve_language&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;auto_detect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;language_auto_detect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;override&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;language_override&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Widget: includes browser_lang as additional fallback
&lt;/span&gt;&lt;span class="n"&gt;detected_lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve_language&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;auto_detect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;language_auto_detect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;override&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;language_override&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;browser_lang&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;browser_lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;lang_hint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_language_instruction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;detected_lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The widget captures the browser language and sends it with each request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;session_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sessionToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;browser_lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;widgetLang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// navigator.language || "es"&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Injecting Language into the LLM System Prompt
&lt;/h2&gt;

&lt;p&gt;Detecting the language isn't enough — you need to &lt;strong&gt;force&lt;/strong&gt; the LLM to respond in that language. This is done by injecting an explicit instruction at the end of the system prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_build_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="n"&gt;language_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;system_message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;identity_prefix&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;mode_prefix&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;_build_system_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;language_hint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;system_message&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;IMPORTANT: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;language_hint&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system_message&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;code&gt;language_hint&lt;/code&gt; is one of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_LANG_INSTRUCTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Respond entirely in English.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Responda inteiramente em português.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Responde completamente en español.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why at the end of the system prompt?&lt;/strong&gt; LLMs tend to give more weight to instructions at the beginning and end of the prompt (recency bias). Putting the language at the end maximizes the probability of compliance, even when the document context is in a different language.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why "IMPORTANT:"?&lt;/strong&gt; For the same reason. LLMs respond better to instructions marked as important. Without this prefix, Llama 3.3 sometimes ignored the language instruction when all context was in another language.&lt;/p&gt;




&lt;h2&gt;
  
  
  Multilingual Embeddings: The Silent Hero
&lt;/h2&gt;

&lt;p&gt;All of this works thanks to an embedding model that &lt;strong&gt;understands multiple languages in the same vector space&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config.py
&lt;/span&gt;&lt;span class="n"&gt;embedding_model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paraphrase-multilingual-MiniLM-L12-v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;paraphrase-multilingual-MiniLM-L12-v2&lt;/code&gt; generates &lt;strong&gt;384-dimensional&lt;/strong&gt; vectors and supports &lt;strong&gt;50+ languages&lt;/strong&gt;. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"How do I reset my password?" (English)&lt;/li&gt;
&lt;li&gt;"¿Cómo reseteo mi contraseña?" (Spanish)&lt;/li&gt;
&lt;li&gt;"Como redefinir minha senha?" (Portuguese)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Produce embeddings &lt;strong&gt;close in vector space&lt;/strong&gt;, despite being in different languages.&lt;/p&gt;

&lt;p&gt;Without multilingual embeddings, an English query would never find Spanish documents. With this model, the user asks in English, pgvector finds Spanish chunks (because the meaning is close), the cross-encoder confirms relevance, and the LLM responds in English. No intermediate translation.&lt;/p&gt;

&lt;p&gt;The reranker is also multilingual (&lt;code&gt;cross-encoder/mmarco-mMiniLMv2-L12-H384-v1&lt;/code&gt;, trained on mMARCO, 14 languages). Crucial because it evaluates query + chunk together — if they're in different languages, it needs to understand both.&lt;/p&gt;




&lt;h2&gt;
  
  
  BM25: The Hardcoded tsvector Limitation
&lt;/h2&gt;

&lt;p&gt;There's an elephant in the room. My BM25 search uses PostgreSQL &lt;code&gt;tsvector&lt;/code&gt; with &lt;code&gt;'spanish'&lt;/code&gt; hardcoded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;document_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ts_rank_cd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plainto_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'spanish'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;knowledge_base_id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;kb_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;plainto_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'spanish'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The problem&lt;/strong&gt;: if the user asks in English, &lt;code&gt;plainto_tsquery('spanish', 'how do I reset')&lt;/code&gt; won't tokenize English words correctly. Spanish stemming converts "reset" differently than English stemming.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why I Didn't Change It
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Vector search compensates&lt;/strong&gt;: BM25 is 30% of hybrid search. If it fails for cross-language queries, vector search (70%) still works perfectly thanks to multilingual embeddings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The OR fallback helps&lt;/strong&gt;: When the AND query finds no results, a fallback to OR (&lt;code&gt;to_tsquery&lt;/code&gt; with &lt;code&gt;|&lt;/code&gt;) is more permissive and rescues partial matches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complexity vs benefit&lt;/strong&gt;: Dynamic tsvector by language would require multiple &lt;code&gt;search_vector&lt;/code&gt; columns or on-the-fly regeneration. The marginal improvement doesn't justify the cost when vector search is the primary component.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;This is a known and documented limitation.&lt;/strong&gt; If your use case has 80%+ queries in English with English documents, you should change &lt;code&gt;'spanish'&lt;/code&gt; to &lt;code&gt;'english'&lt;/code&gt; — or better yet, to &lt;code&gt;'simple'&lt;/code&gt; which does basic tokenization without language-specific stemming.&lt;/p&gt;




&lt;h2&gt;
  
  
  Content Rules: Moderation Without LLM
&lt;/h2&gt;

&lt;p&gt;Beyond detecting language, I implemented a content rules system that acts &lt;strong&gt;before&lt;/strong&gt; the RAG pipeline. Three types:&lt;/p&gt;

&lt;h3&gt;
  
  
  BLOCK: Stop the Query
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;evaluate_block_redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ContentRule&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;q_lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;trigger&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;triggers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;q_lower&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a BLOCK trigger matches, the user receives the configured &lt;code&gt;response&lt;/code&gt; and the RAG pipeline &lt;strong&gt;never executes&lt;/strong&gt;. Zero tokens, zero LLM latency.&lt;/p&gt;

&lt;h3&gt;
  
  
  REDIRECT and FILTER
&lt;/h3&gt;

&lt;p&gt;REDIRECT uses the same mechanism as BLOCK but to redirect ("For billing inquiries, contact &lt;a href="mailto:support@company.com"&gt;support@company.com&lt;/a&gt;"). FILTER is different — it acts &lt;strong&gt;post-retrieval&lt;/strong&gt;, removing chunks before sending them to the LLM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_filter_terms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ContentRule&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;terms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;triggers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;filter_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;filter_terms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;filter_terms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;
    &lt;span class="n"&gt;filtered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;content_lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;term&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content_lower&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;filter_terms&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;filtered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;filtered&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful when your documents have sections you don't want the LLM to use as context (internal pricing, confidential information in partially public documents).&lt;/p&gt;

&lt;h3&gt;
  
  
  The Full Flow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 1. Content rules check (BLOCK / REDIRECT) — before everything
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content_rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;rule_match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluate_block_redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content_rules&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rule_match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content_blocked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rule_match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rule_match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;})}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

&lt;span class="c1"&gt;# 2. Normal RAG pipeline (vector + BM25 + rerank + MMR)
&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;search_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 3. Apply FILTER content rules — after retrieval, before LLM
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content_rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;blocked_terms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_filter_terms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content_rules&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;blocked_terms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;filter_blocked_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;blocked_terms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 4. LLM with language hint
&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stream_chat_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;language_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lang_hint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything is stored in each Knowledge Base's &lt;code&gt;rag_config&lt;/code&gt; JSONB — no separate table, no migrations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ContentRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;block&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redirect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;triggers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RAGConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# ... retrieval, llm, processing configs ...
&lt;/span&gt;    &lt;span class="n"&gt;language_auto_detect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;language_override&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^(es|en|pt)$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;content_rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ContentRule&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each KB has its own detection, its own override, and its own content rules.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frontend and Tracking
&lt;/h2&gt;

&lt;p&gt;In the RAG configuration dialog, the admin has an auto-detection toggle, forced language selector, and inline content rules CRUD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;languageAutoDetect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;languageOverride&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contentRules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reactive&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ContentRule&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;([])&lt;/span&gt;

&lt;span class="c1"&gt;// On save&lt;/span&gt;
&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;language_auto_detect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;languageAutoDetect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;language_override&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;languageOverride&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content_rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;contentRules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;toRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the override is active, the auto-detection toggle is visually disabled — no point detecting if you've already forced a language.&lt;/p&gt;

&lt;p&gt;Every query is logged with the detected language (migration 027: &lt;code&gt;detected_language VARCHAR(5)&lt;/code&gt; on &lt;code&gt;usage_logs&lt;/code&gt;), which allows analyzing language distribution per KB and detecting failure patterns.&lt;/p&gt;




&lt;h2&gt;
  
  
  Edge Cases and Limitations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Very Short Queries
&lt;/h3&gt;

&lt;p&gt;"Reset" — is it English or Spanish (used as an anglicism)? With a single word, there's not enough context. The 2-word minimum threshold protects against this, but it means 1-2 word queries always return the default.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Spanglish and Code-Switching
&lt;/h3&gt;

&lt;p&gt;"Necesito help con el login" — tie between ES and EN. Since the score doesn't reach 2 for English, it returns "es". Correct, but not for the ideal reasons.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Portuguese vs Spanish
&lt;/h3&gt;

&lt;p&gt;Many words are identical ("como", "para", "sobre"). Differentiation depends on exclusive words like &lt;code&gt;você&lt;/code&gt;, &lt;code&gt;não&lt;/code&gt;, &lt;code&gt;obrigado&lt;/code&gt;. Without them, a Brazilian user may be detected as Spanish-speaking.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Pure Technical Queries
&lt;/h3&gt;

&lt;p&gt;"HTTP 403 POST /api/users" — score 0 in all languages, returns default. For queries without natural words, language matters less.&lt;/p&gt;




&lt;h2&gt;
  
  
  Numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Detection latency&lt;/td&gt;
&lt;td&gt;~0.01ms (set intersection)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Word lists total&lt;/td&gt;
&lt;td&gt;~120 words (40 EN + 45 PT + 35 ES)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confidence threshold&lt;/td&gt;
&lt;td&gt;2 words minimum&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Languages supported&lt;/td&gt;
&lt;td&gt;3 (ES, EN, PT)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multilingual embeddings&lt;/td&gt;
&lt;td&gt;50+ languages (384d)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multilingual cross-encoder&lt;/td&gt;
&lt;td&gt;14 languages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content rules per KB&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total overhead&lt;/td&gt;
&lt;td&gt;&amp;lt; 1ms per request&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. You Don't Need an ML Model to Detect 3 Languages
&lt;/h3&gt;

&lt;p&gt;For a small set of languages, word lists + scoring is absurdly effective. An ML model needs to be loaded in memory, has cold start, and its accuracy for short queries (5-10 words) isn't significantly better than a well-calibrated heuristic approach.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The Priority Chain Is More Important Than Detection
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;resolve_language()&lt;/code&gt; function is more valuable than &lt;code&gt;detect_language()&lt;/code&gt;. Being able to combine admin override + automatic detection + browser signal in a configurable chain covers 99% of cases. Heuristic detection alone would cover maybe 85%.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. browser_lang Is Underestimated
&lt;/h3&gt;

&lt;p&gt;In embedded widgets, the browser language is an extremely strong signal. If the browser is set to &lt;code&gt;pt-BR&lt;/code&gt; and the query is ambiguous, the user is almost certainly Brazilian. Adding this field to the widget request was one line of code that significantly improved the experience for short or ambiguous queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Content Rules Are More Useful Than Expected
&lt;/h3&gt;

&lt;p&gt;I started implementing content rules as "nice to have" for basic moderation. In practice, admins use them creatively: redirect billing questions to the right email, block queries about competitors, filter internal sections from documents. They're a control layer that bypasses the LLM.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Multilingual Embeddings Do 80% of the Work
&lt;/h3&gt;

&lt;p&gt;The uncomfortable truth is that if you use &lt;code&gt;paraphrase-multilingual-MiniLM-L12-v2&lt;/code&gt; for embeddings, cross-language retrieval works pretty well &lt;strong&gt;without doing anything else&lt;/strong&gt;. Language detection is primarily for controlling the &lt;em&gt;response&lt;/em&gt; language of the LLM, not retrieval.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Logging the Detected Language Is Essential
&lt;/h3&gt;

&lt;p&gt;Without the &lt;code&gt;detected_language&lt;/code&gt; field in &lt;code&gt;usage_logs&lt;/code&gt;, I'd be guessing if detection works well. With the data, I can see patterns: "15% of queries to the Brazil widget are detected as Spanish" tells me I need to expand the Portuguese word lists.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Character n-gram detection&lt;/strong&gt;: More robust for short queries than word lists&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic tsvector&lt;/strong&gt;: Choose dictionary based on detected language (&lt;code&gt;'english'&lt;/code&gt;, &lt;code&gt;'portuguese'&lt;/code&gt;, &lt;code&gt;'simple'&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More languages&lt;/strong&gt;: French and German only need new word lists&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic stop words&lt;/strong&gt;: Currently only Spanish — should adapt to the detected language&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Multilingual support in RAG doesn't need to be complicated or expensive. Multilingual embeddings as the foundation, heuristic detection in ~90 lines, and a well-designed priority chain cover 99% of cases for ES/EN/PT.&lt;/p&gt;

&lt;p&gt;What matters isn't the detection itself — it's the &lt;strong&gt;architecture&lt;/strong&gt;: configurable per KB, with reasonable fallbacks, that logs its decisions for improvement, and where content rules give admins control without going through the LLM. Sometimes the simplest solution is the right one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you work with multilingual RAG and found other edge cases, drop a comment. And if this article was useful, a like helps it reach more people.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>rag</category>
      <category>ai</category>
      <category>nlp</category>
    </item>
    <item>
      <title>Detección de idioma sin APIs externas para un sistema RAG multilingüe</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Tue, 14 Apr 2026 13:11:00 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/deteccion-de-idioma-sin-apis-externas-para-un-sistema-rag-multilingue-43jn</link>
      <guid>https://dev.to/martin_palopoli/deteccion-de-idioma-sin-apis-externas-para-un-sistema-rag-multilingue-43jn</guid>
      <description>&lt;p&gt;Implementé inteligencia lingüística completa para un motor RAG multi-tenant: detección heurística de idioma (ES/EN/PT) con latencia cero, cadena de prioridad configurable, inyección en prompts del LLM, reglas de contenido (BLOCK/REDIRECT/FILTER) para moderación, y &lt;code&gt;browser_lang&lt;/code&gt; desde el widget. Todo sin APIs externas, con ~90 líneas de código.&lt;/p&gt;




&lt;h2&gt;
  
  
  El problema multilingüe en RAG
&lt;/h2&gt;

&lt;p&gt;La mayoría de tutoriales RAG asumen un solo idioma. En producción te encontrás con:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Queries en inglés contra documentos en español (o viceversa)&lt;/li&gt;
&lt;li&gt;Widgets embebidos en sitios brasileños recibiendo portugués&lt;/li&gt;
&lt;li&gt;El LLM responde en el idioma del contexto en vez del idioma del usuario&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Las "soluciones" comunes son APIs de detección (Google, AWS Comprehend) que agregan latencia y costo, librerías como langdetect/fasttext que son dependencias pesadas para 3 idiomas, o directamente ignorar el problema.&lt;/p&gt;

&lt;p&gt;Necesitaba algo con &lt;strong&gt;latencia cero&lt;/strong&gt;, sin dependencias, y &lt;strong&gt;configurable por Knowledge Base&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  La solución: detección heurística + cadena de prioridad
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Enfoque general
&lt;/h3&gt;

&lt;p&gt;En vez de usar un modelo estadístico completo, ataco el problema en capas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Query del usuario
       │
       ▼
┌──────────────────────┐
│  1. Override fijo?    │──→ Si admin forzó "en": usar "en" siempre
└──────┬───────────────┘
       │ No override
       ▼
┌──────────────────────┐
│  2. Heurística        │──→ Contar palabras clave ES/EN/PT
└──────┬───────────────┘
       │ Score &amp;lt; threshold
       ▼
┌──────────────────────┐
│  3. Browser lang?     │──→ Idioma del navegador (widget)
└──────┬───────────────┘
       │ No disponible
       ▼
    Default: "es"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Las word lists
&lt;/h3&gt;

&lt;p&gt;El corazón de la detección son tres sets de palabras comunes por idioma:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;

&lt;span class="n"&gt;_PUNCT_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^\w\s]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UNICODE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;_EN_WORDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;how&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;what&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;where&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;when&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;why&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;who&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;which&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;can&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;could&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;would&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;should&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;does&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;do&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;are&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;this&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;that&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;help&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;please&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tell&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;explain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;show&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;need&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;want&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;have&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;about&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;they&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;there&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;their&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;been&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;just&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;also&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;very&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;some&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;any&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;other&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;than&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;into&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;_PT_WORDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;como&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;onde&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quando&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;porque&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quem&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;qual&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poderia&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;esta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;são&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;isso&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;isto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ajuda&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ajudar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mostre&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;explique&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;você&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vocês&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;obrigado&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;obrigada&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preciso&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quero&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenho&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sobre&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;para&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sua&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;também&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;muito&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;algum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mais&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ainda&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aqui&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;depois&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;antes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entre&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;posso&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gostaria&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fazer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dizer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;favor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bom&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;boa&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dia&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;noite&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;olá&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sim&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;não&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bem&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tudo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;_ES_WORDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;qué&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cómo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dónde&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cuándo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cuál&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quién&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;puedo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;podría&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;necesito&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiero&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tengo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;también&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aquí&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;después&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ahora&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entonces&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pero&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sino&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aunque&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;desde&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hasta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hacia&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;según&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ayuda&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;explicar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mostrar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;buscar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hola&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gracias&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;información&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pregunta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;respuesta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;por&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;favor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Decisiones clave en las listas:&lt;/strong&gt; Inglés se enfoca en function words (&lt;code&gt;the&lt;/code&gt;, &lt;code&gt;is&lt;/code&gt;, &lt;code&gt;are&lt;/code&gt;) que casi nunca aparecen en español/portugués. Portugués incluye diferenciadores clave vs ES: &lt;code&gt;você&lt;/code&gt;, &lt;code&gt;não&lt;/code&gt;, &lt;code&gt;obrigado&lt;/code&gt;, &lt;code&gt;tudo&lt;/code&gt;. Español usa acentos cuando es posible (&lt;code&gt;qué&lt;/code&gt;, &lt;code&gt;cómo&lt;/code&gt;, &lt;code&gt;dónde&lt;/code&gt;) — un usuario que escribe con acentos es casi seguro que está en español.&lt;/p&gt;

&lt;h3&gt;
  
  
  La función de detección
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;detect_language&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Detect query language. Returns &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, or &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;. Default &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_PUNCT_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;_EN_WORDS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;_PT_WORDS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;_ES_WORDS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;¿Por qué threshold de 2?&lt;/strong&gt; Con una sola palabra coincidente, el riesgo de falso positivo es alto. "como" existe en español y portugués. "para" también. Pero si encontramos 2+ palabras de un idioma, la probabilidad de acertar sube dramáticamente.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Por qué default "es"?&lt;/strong&gt; Mi caso de uso principal es Latinoamérica. Si no puedo detectar con confianza, español es la apuesta más segura. Esto es configurable — podés cambiar el default según tu mercado.&lt;/p&gt;




&lt;h2&gt;
  
  
  La cadena de prioridad: &lt;code&gt;resolve_language()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;La detección heurística es solo una capa. La función real que decide el idioma es &lt;code&gt;resolve_language()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_LANG_INSTRUCTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Respond entirely in English.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Responda inteiramente em português.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Responde completamente en español.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_language&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;auto_detect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;override&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;browser_lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Resolve the final language for a query.
    Priority: override &amp;gt; heuristic detection &amp;gt; browser fallback &amp;gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. Admin override: ignora todo, fuerza idioma
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;override&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;override&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;override&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Heuristic: analiza el texto
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;auto_detect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;detected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;detect_language&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# Si detectó algo que no es el default, confiar
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;detected&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;browser_lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;detected&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. Browser lang: idioma del navegador del usuario
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;browser_lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;bl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser_lang&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()[:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bl&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;bl&lt;/span&gt;

    &lt;span class="c1"&gt;# 4. Default
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  ¿Por qué esta prioridad?
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Nivel&lt;/th&gt;
&lt;th&gt;Fuente&lt;/th&gt;
&lt;th&gt;Cuándo gana&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Override del admin&lt;/td&gt;
&lt;td&gt;Siempre. Si el admin dice "esta KB es en inglés", se respeta&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Heurística&lt;/td&gt;
&lt;td&gt;Si detecta EN o PT con confianza (score &amp;gt;= 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Browser lang&lt;/td&gt;
&lt;td&gt;Si la heurística no pudo decidir (devolvió "es" por default)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Default "es"&lt;/td&gt;
&lt;td&gt;Último recurso&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;El truco sutil&lt;/strong&gt;: si la heurística devuelve "es", podría ser un verdadero español O un "no pude detectar". En ese caso, le damos la oportunidad al &lt;code&gt;browser_lang&lt;/code&gt;. Si el navegador del usuario está en portugués y la query es ambigua, probablemente es portugués.&lt;/p&gt;

&lt;h3&gt;
  
  
  Uso en chat vs widget
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Chat: sin browser_lang
&lt;/span&gt;&lt;span class="n"&gt;detected_lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve_language&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;auto_detect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;language_auto_detect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;override&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;language_override&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Widget: incluye browser_lang como fallback adicional
&lt;/span&gt;&lt;span class="n"&gt;detected_lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve_language&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;auto_detect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;language_auto_detect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;override&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;language_override&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;browser_lang&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;browser_lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;lang_hint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_language_instruction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;detected_lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El widget captura el idioma del navegador y lo envía en cada request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;session_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sessionToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;browser_lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;widgetLang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// navigator.language || "es"&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Inyectando el idioma en el system prompt del LLM
&lt;/h2&gt;

&lt;p&gt;Detectar el idioma no alcanza — hay que &lt;strong&gt;forzar&lt;/strong&gt; al LLM a responder en ese idioma. Esto se hace inyectando una instrucción explícita al final del system prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_build_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="n"&gt;language_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;system_message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;identity_prefix&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;mode_prefix&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;_build_system_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;language_hint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;system_message&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;IMPORTANTE: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;language_hint&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system_message&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Donde &lt;code&gt;language_hint&lt;/code&gt; es uno de:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_LANG_INSTRUCTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Respond entirely in English.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Responda inteiramente em português.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Responde completamente en español.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;¿Por qué al final del system prompt?&lt;/strong&gt; Los LLMs tienden a darle más peso a las instrucciones al principio y al final del prompt (recency bias). Poniendo el idioma al final, maximizamos la probabilidad de que lo respete, incluso cuando el contexto de los documentos está en otro idioma.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Por qué "IMPORTANTE:"?&lt;/strong&gt; Por la misma razón. Los LLMs responden mejor a instrucciones marcadas como importantes. Sin este prefijo, Llama 3.3 a veces ignoraba la instrucción de idioma cuando todo el contexto estaba en otro idioma.&lt;/p&gt;




&lt;h2&gt;
  
  
  Embeddings multilingües: el héroe silencioso
&lt;/h2&gt;

&lt;p&gt;Todo esto funciona gracias a un modelo de embeddings que &lt;strong&gt;entiende múltiples idiomas en el mismo espacio vectorial&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config.py
&lt;/span&gt;&lt;span class="n"&gt;embedding_model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paraphrase-multilingual-MiniLM-L12-v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;paraphrase-multilingual-MiniLM-L12-v2&lt;/code&gt; genera vectores de &lt;strong&gt;384 dimensiones&lt;/strong&gt; y soporta &lt;strong&gt;50+ idiomas&lt;/strong&gt;. Esto significa que:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"How do I reset my password?" (inglés)&lt;/li&gt;
&lt;li&gt;"¿Cómo reseteo mi contraseña?" (español)&lt;/li&gt;
&lt;li&gt;"Como redefinir minha senha?" (portugués)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Producen embeddings &lt;strong&gt;cercanos en el espacio vectorial&lt;/strong&gt;, a pesar de estar en idiomas diferentes.&lt;/p&gt;

&lt;p&gt;Sin embeddings multilingües, una query en inglés jamás encontraría documentos en español. Con este modelo, el usuario pregunta en inglés, pgvector encuentra chunks en español (porque el significado es cercano), el cross-encoder confirma la relevancia, y el LLM responde en inglés. Sin traducción intermedia.&lt;/p&gt;

&lt;p&gt;El reranker también es multilingüe (&lt;code&gt;cross-encoder/mmarco-mMiniLMv2-L12-H384-v1&lt;/code&gt;, entrenado en mMARCO, 14 idiomas). Crucial porque evalúa query + chunk juntos — si están en idiomas diferentes, necesita entender ambos.&lt;/p&gt;




&lt;h2&gt;
  
  
  BM25: la limitación del tsvector hardcodeado
&lt;/h2&gt;

&lt;p&gt;Hay un elefante en la habitación. Mi búsqueda BM25 usa PostgreSQL &lt;code&gt;tsvector&lt;/code&gt; con la configuración &lt;code&gt;'spanish'&lt;/code&gt; hardcodeada:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;document_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ts_rank_cd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plainto_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'spanish'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;knowledge_base_id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;kb_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;plainto_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'spanish'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;El problema&lt;/strong&gt;: si el usuario pregunta en inglés, &lt;code&gt;plainto_tsquery('spanish', 'how do I reset')&lt;/code&gt; no va a tokenizar correctamente las palabras inglesas. El stemming de español convierte "reset" diferente que el stemming de inglés.&lt;/p&gt;

&lt;h3&gt;
  
  
  ¿Por qué no lo cambié?
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;La búsqueda vectorial compensa&lt;/strong&gt;: El BM25 es el 30% de la búsqueda híbrida. Si falla para queries cross-language, la búsqueda vectorial (70%) sigue funcionando perfectamente gracias a los embeddings multilingües.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El fallback OR ayuda&lt;/strong&gt;: Cuando el AND query no encuentra resultados, un fallback a OR (&lt;code&gt;to_tsquery&lt;/code&gt; con &lt;code&gt;|&lt;/code&gt;) es más permisivo y rescata matches parciales.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complejidad vs beneficio&lt;/strong&gt;: tsvector dinámico por idioma requeriría múltiples &lt;code&gt;search_vector&lt;/code&gt; columns o regeneración al vuelo. La mejora marginal no justifica el costo cuando el vector search es el componente principal.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Esto es una limitación conocida y documentada.&lt;/strong&gt; Si tu caso de uso tiene 80%+ de queries en inglés con documentos en inglés, deberías cambiar &lt;code&gt;'spanish'&lt;/code&gt; por &lt;code&gt;'english'&lt;/code&gt; — o mejor aún, por &lt;code&gt;'simple'&lt;/code&gt; que hace tokenización básica sin stemming idioma-específico.&lt;/p&gt;




&lt;h2&gt;
  
  
  Content Rules: moderación sin LLM
&lt;/h2&gt;

&lt;p&gt;Además de detectar idioma, implementé un sistema de reglas de contenido que actúa &lt;strong&gt;antes&lt;/strong&gt; del pipeline RAG. Tres tipos:&lt;/p&gt;

&lt;h3&gt;
  
  
  BLOCK: detener la query
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;evaluate_block_redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ContentRule&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;q_lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;trigger&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;triggers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;q_lower&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Si un trigger de tipo BLOCK matchea, el usuario recibe el &lt;code&gt;response&lt;/code&gt; configurado y el pipeline RAG &lt;strong&gt;nunca se ejecuta&lt;/strong&gt;. Cero tokens, cero latencia de LLM.&lt;/p&gt;

&lt;h3&gt;
  
  
  REDIRECT y FILTER
&lt;/h3&gt;

&lt;p&gt;REDIRECT usa el mismo mecanismo que BLOCK pero para redirigir ("Para facturación, contacta &lt;a href="mailto:soporte@empresa.com"&gt;soporte@empresa.com&lt;/a&gt;"). FILTER es diferente — actúa &lt;strong&gt;post-retrieval&lt;/strong&gt;, eliminando chunks antes de enviarlos al LLM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_filter_terms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ContentRule&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;terms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;triggers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;filter_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;filter_terms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;filter_terms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;
    &lt;span class="n"&gt;filtered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;content_lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;term&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content_lower&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;filter_terms&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;filtered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;filtered&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Útil cuando tus documentos tienen secciones que no querés que el LLM use como contexto (precios internos, información confidencial en documentos parcialmente públicos).&lt;/p&gt;

&lt;h3&gt;
  
  
  El flujo completo
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 1. Content rules check (BLOCK / REDIRECT) — antes de todo
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content_rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;rule_match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluate_block_redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content_rules&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rule_match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content_blocked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rule_match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rule_match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;})}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

&lt;span class="c1"&gt;# 2. Pipeline RAG normal (vector + BM25 + rerank + MMR)
&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;search_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 3. Apply FILTER content rules — después del retrieval, antes del LLM
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content_rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;blocked_terms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_filter_terms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content_rules&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;blocked_terms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;filter_blocked_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;blocked_terms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 4. LLM con language hint
&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stream_chat_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;language_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lang_hint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Todo se almacena en el &lt;code&gt;rag_config&lt;/code&gt; JSONB de cada Knowledge Base — sin tabla separada, sin migraciones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ContentRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;block&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redirect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;triggers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RAGConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# ... retrieval, llm, processing configs ...
&lt;/span&gt;    &lt;span class="n"&gt;language_auto_detect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;language_override&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^(es|en|pt)$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;content_rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ContentRule&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cada KB tiene su propia detección, su propio override, y sus propias reglas de contenido.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frontend y tracking
&lt;/h2&gt;

&lt;p&gt;En el diálogo de configuración RAG, el admin tiene toggle de auto-detección, selector de idioma forzado, y CRUD inline de content rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;languageAutoDetect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;languageOverride&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contentRules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reactive&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ContentRule&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;([])&lt;/span&gt;

&lt;span class="c1"&gt;// Al guardar&lt;/span&gt;
&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;language_auto_detect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;languageAutoDetect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;language_override&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;languageOverride&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content_rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;contentRules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;toRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Si el override está activo, el toggle de auto-detección se deshabilita visualmente — no tiene sentido detectar si ya forzaste un idioma.&lt;/p&gt;

&lt;p&gt;Cada query se loguea con el idioma detectado (migration 027: &lt;code&gt;detected_language VARCHAR(5)&lt;/code&gt; en &lt;code&gt;usage_logs&lt;/code&gt;), lo que permite analizar distribución de idiomas por KB y detectar patrones de fallo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Edge cases y limitaciones
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Queries muy cortas
&lt;/h3&gt;

&lt;p&gt;"Reset" — ¿es inglés o español (se usa como anglicismo)? Con una sola palabra, no hay suficiente contexto. El threshold de 2 palabras mínimas protege contra esto, pero significa que queries de 1-2 palabras siempre devuelven el default.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Spanglish y code-switching
&lt;/h3&gt;

&lt;p&gt;"Necesito help con el login" — empate entre ES y EN. Como el score no llega a 2 para inglés, devuelve "es". Correcto, pero no por las razones ideales.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Portugués vs español
&lt;/h3&gt;

&lt;p&gt;Muchas palabras son idénticas ("como", "para", "sobre"). La diferenciación depende de palabras exclusivas como &lt;code&gt;você&lt;/code&gt;, &lt;code&gt;não&lt;/code&gt;, &lt;code&gt;obrigado&lt;/code&gt;. Sin ellas, un brasileño puede ser detectado como hispanohablante.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Queries técnicas puras
&lt;/h3&gt;

&lt;p&gt;"HTTP 403 POST /api/users" — score 0 en todos los idiomas, devuelve el default. Para queries sin palabras naturales, el idioma importa menos.&lt;/p&gt;




&lt;h2&gt;
  
  
  Números
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspecto&lt;/th&gt;
&lt;th&gt;Valor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Latencia de detección&lt;/td&gt;
&lt;td&gt;~0.01ms (set intersection)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Word lists total&lt;/td&gt;
&lt;td&gt;~120 palabras (40 EN + 45 PT + 35 ES)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Threshold de confianza&lt;/td&gt;
&lt;td&gt;2 palabras mínimo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idiomas soportados&lt;/td&gt;
&lt;td&gt;3 (ES, EN, PT)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embeddings multilingües&lt;/td&gt;
&lt;td&gt;50+ idiomas (384d)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-encoder multilingüe&lt;/td&gt;
&lt;td&gt;14 idiomas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content rules por KB&lt;/td&gt;
&lt;td&gt;Ilimitadas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Overhead total&lt;/td&gt;
&lt;td&gt;&amp;lt; 1ms por request&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Lecciones aprendidas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. No necesitás un modelo de ML para detectar 3 idiomas
&lt;/h3&gt;

&lt;p&gt;Para un set acotado de idiomas, word lists + scoring es absurdamente efectivo. Un modelo de ML necesita cargarse en memoria, tiene cold start, y su accuracy para queries cortas (5-10 palabras) no es significativamente mejor que un enfoque heurístico bien calibrado.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. La cadena de prioridad es más importante que la detección
&lt;/h3&gt;

&lt;p&gt;La función &lt;code&gt;resolve_language()&lt;/code&gt; es más valiosa que &lt;code&gt;detect_language()&lt;/code&gt;. Poder combinar override del admin + detección automática + señal del navegador en una cadena configurable cubre el 99% de los casos. La detección heurística sola cubriría quizás el 85%.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. El browser_lang es subestimado
&lt;/h3&gt;

&lt;p&gt;En widgets embebidos, el idioma del navegador es una señal fortísima. Si el navegador está en &lt;code&gt;pt-BR&lt;/code&gt; y la query es ambigua, es casi seguro que el usuario es brasileño. Añadir este campo al request del widget fue una línea de código que mejoró significativamente la experiencia para queries cortas o ambiguas.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Content rules son más útiles de lo esperado
&lt;/h3&gt;

&lt;p&gt;Empecé implementando content rules como "nice to have" para moderación básica. En la práctica, los admins los usan creativamente: redirigir preguntas de facturación al email correcto, bloquear queries sobre competidores, filtrar secciones internas de documentos. Son una capa de control que no pasa por el LLM.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Embeddings multilingües hacen el 80% del trabajo
&lt;/h3&gt;

&lt;p&gt;La verdad incómoda es que si usás &lt;code&gt;paraphrase-multilingual-MiniLM-L12-v2&lt;/code&gt; para embeddings, el cross-language retrieval funciona bastante bien &lt;strong&gt;sin hacer nada más&lt;/strong&gt;. La detección de idioma es principalmente para controlar el idioma de la &lt;em&gt;respuesta&lt;/em&gt; del LLM, no del retrieval.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Loguear el idioma detectado es esencial
&lt;/h3&gt;

&lt;p&gt;Sin el campo &lt;code&gt;detected_language&lt;/code&gt; en &lt;code&gt;usage_logs&lt;/code&gt;, estaría adivinando si la detección funciona bien. Con los datos, puedo ver patrones: "el 15% de queries al widget en Brasil se detectan como español" me dice que necesito expandir las word lists de portugués.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lo que sigue
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Detección por n-gramas de caracteres&lt;/strong&gt;: Más robusto para queries cortas que word lists&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tsvector dinámico&lt;/strong&gt;: Elegir el diccionario según idioma detectado (&lt;code&gt;'english'&lt;/code&gt;, &lt;code&gt;'portuguese'&lt;/code&gt;, &lt;code&gt;'simple'&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Más idiomas&lt;/strong&gt;: Francés y alemán solo requieren nuevas word lists&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop words dinámicas&lt;/strong&gt;: Hoy son solo español — deberían adaptarse al idioma&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Conclusión
&lt;/h2&gt;

&lt;p&gt;El soporte multilingüe en RAG no necesita ser complicado ni caro. Embeddings multilingües como base, detección heurística de ~90 líneas, y una cadena de prioridad bien diseñada cubren el 99% de los casos para ES/EN/PT.&lt;/p&gt;

&lt;p&gt;Lo importante no es la detección en sí — es la &lt;strong&gt;arquitectura&lt;/strong&gt;: configurable por KB, con fallbacks razonables, que loguea sus decisiones para poder mejorar, y que las content rules den control al admin sin pasar por el LLM. A veces la solución más simple es la correcta.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Si trabajás con RAG multilingüe y encontraste otros edge cases, dejá un comentario. Y si este artículo te fue útil, un like ayuda a que llegue a más personas.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>python</category>
      <category>ai</category>
      <category>nlp</category>
    </item>
    <item>
      <title>Semantic Cache and FAQ Matching: How I Cut LLM Costs by 40% in My RAG Engine</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Tue, 14 Apr 2026 13:10:00 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/semantic-cache-and-faq-matching-how-i-cut-llm-costs-by-40-in-my-rag-engine-3ffo</link>
      <guid>https://dev.to/martin_palopoli/semantic-cache-and-faq-matching-how-i-cut-llm-costs-by-40-in-my-rag-engine-3ffo</guid>
      <description>&lt;p&gt;Every RAG query costs money: embedding + LLM tokens. I implemented three optimization layers that reduce real cost by 30-45%: &lt;strong&gt;FAQ matching&lt;/strong&gt; (curated answers at zero cost), &lt;strong&gt;semantic cache&lt;/strong&gt; (pgvector with similarity &amp;gt;= 0.95), and &lt;strong&gt;auto-FAQ generation&lt;/strong&gt; from frequent unanswered queries. All with production code and a fallback that keeps the service running when the LLM budget runs out.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: Every Query Costs Real Money
&lt;/h2&gt;

&lt;p&gt;In previous articles I built a RAG pipeline with hybrid search, reranking and streaming. Works well. But in production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Typical query:  embedding + search + LLM (~800 tokens) ≈ $0.0003
1,000 queries/day × 30 days = 30,000 queries/month
30,000 × $0.0003 = ~$9/month per tenant
50 tenants = ~$450/month in LLM alone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: &lt;strong&gt;35-40% of queries are repeated or very similar&lt;/strong&gt;. Each one is money burned.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture of the 3 Savings Layers
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User query
     │
     ▼
┌─────────────┐
│  FAQ Match   │──→ If score ≥ 0.85: curated answer (LLM cost = $0)
└─────┬───────┘
      │ No match
      ▼
┌─────────────────┐
│ Semantic Cache   │──→ If similarity ≥ 0.95: cached response ($0)
└─────┬───────────┘
      │ Cache miss
      ▼
┌─────────────────┐
│ Budget Check     │──→ If budget exhausted: FAQ-only fallback
└─────┬───────────┘
      │ Budget OK
      ▼
  Full RAG pipeline → fire-and-forget: store in cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Layer 1: FAQ Matching — The Most Profitable Investment
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;match_faq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;query_embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embedding_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embed_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;kb_ids_str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;kb_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;kb_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        SELECT id, question, answer, attachments,
               1 - (embedding &amp;lt;=&amp;gt; CAST(:embedding AS vector)) as score
        FROM faqs
        WHERE knowledge_base_id IN (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kb_ids_str&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)
          AND is_active = true AND embedding IS NOT NULL
        ORDER BY embedding &amp;lt;=&amp;gt; CAST(:embedding AS vector)
        LIMIT 1
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mappings&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="c1"&gt;# Increment hit_count atomically
&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FAQ&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FAQ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hit_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FAQ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hit_count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;faq_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model uses an HNSW index on the embedding so the search takes ~15ms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FAQ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;faqs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;384&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;hit_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;server_default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;__table_args__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ix_faqs_embedding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;postgresql_using&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hnsw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;postgresql_ops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
              &lt;span class="n"&gt;postgresql_with&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;m&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ef_construction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Threshold Tuning: From 0.75 to 0.85
&lt;/h3&gt;

&lt;p&gt;Started at 0.75. Too low — got false positives where "How do I configure the widget?" matched "What is a widget?". Raised to 0.85 and false positives disappeared:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Stored FAQ&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;Match?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"how do I reset my password"&lt;/td&gt;
&lt;td&gt;"How do I change my password?"&lt;/td&gt;
&lt;td&gt;0.91&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"do you accept visa?"&lt;/td&gt;
&lt;td&gt;"What payment methods do you accept?"&lt;/td&gt;
&lt;td&gt;0.87&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"how do I configure the widget"&lt;/td&gt;
&lt;td&gt;"What is a widget?"&lt;/td&gt;
&lt;td&gt;0.72&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When there's a match, the full pipeline doesn't execute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;faq_match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;faq_match&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;faq_match&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="c1"&gt;# FAQ matches are free — don't increment billing counters
&lt;/span&gt;    &lt;span class="nf"&gt;fire_and_forget_log_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;query_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;faq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;response_tokens=0&lt;/code&gt; and &lt;code&gt;query_type="faq"&lt;/code&gt;&lt;/strong&gt; — fundamental for savings metrics.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 2: Semantic Cache — pgvector as Intelligent Cache
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Concept
&lt;/h3&gt;

&lt;p&gt;If someone asks "How do I reset my password?" and 2 hours ago another asked "How can I change my password?", the answer is the same. Semantic cache detects that equivalence by comparing embeddings, not exact strings.&lt;/p&gt;

&lt;p&gt;The model stores the query embedding, response, sources, and a hash of the RAG configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ResponseCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response_cache&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;query_embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;384&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;query_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;knowledge_base_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;as_uuid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;rag_config_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;hit_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;server_default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Config Hash: Scoping by Configuration
&lt;/h3&gt;

&lt;p&gt;If the admin changes RAG parameters, cached answers are no longer valid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;compute_config_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retrieval_config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;key_fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;candidate_k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;retrieval_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;candidate_k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rerank_top_n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;retrieval_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rerank_top_n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;top_k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;retrieval_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lambda_param&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;retrieval_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lambda_param&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_per_doc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;retrieval_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_per_doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bm25_weight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;retrieval_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bm25_weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;language&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;language&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_fields&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Lookup: Similarity &amp;gt;= 0.95
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;CACHE_SIMILARITY_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lookup_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rag_config_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        SELECT id, response_text, sources, confidence,
               1 - (query_embedding &amp;lt;=&amp;gt; CAST(:embedding AS vector)) AS similarity
        FROM response_cache
        WHERE tenant_id = :tid AND expires_at &amp;gt; now()
          AND rag_config_hash = :config_hash
          AND knowledge_base_ids @&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kb_array&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
        ORDER BY query_embedding &amp;lt;=&amp;gt; CAST(:embedding AS vector)
        LIMIT 1
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mappings&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;similarity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;CACHE_SIMILARITY_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE response_cache SET hit_count = hit_count + 1 WHERE id = :cid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response_text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response_text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why 0.95?&lt;/strong&gt; At 0.90 I had incorrect cache hits:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query A&lt;/th&gt;
&lt;th&gt;Query B&lt;/th&gt;
&lt;th&gt;Similarity&lt;/th&gt;
&lt;th&gt;Same answer?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"How do I export to CSV"&lt;/td&gt;
&lt;td&gt;"How do I download data as CSV"&lt;/td&gt;
&lt;td&gt;0.97&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"How do I configure the webhook"&lt;/td&gt;
&lt;td&gt;"How do I test the webhook"&lt;/td&gt;
&lt;td&gt;0.92&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"How do I add users"&lt;/td&gt;
&lt;td&gt;"How do I delete users"&lt;/td&gt;
&lt;td&gt;0.91&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Fire-and-Forget Storage
&lt;/h3&gt;

&lt;p&gt;You can't block SSE streaming to save to cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fire_and_forget_store_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                 &lt;span class="n"&gt;kb_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rag_config_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                 &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl_hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;168&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_store&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;async_session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;store_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Failed to store cache: %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;loop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_running_loop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_store&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;  &lt;span class="c1"&gt;# No event loop (tests) — skip
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the condition for caching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retrieval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_enabled&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;kb_ids&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;low_confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;fire_and_forget_store_cache&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;not low_confidence&lt;/code&gt;&lt;/strong&gt;: we don't cache bad answers.&lt;/p&gt;




&lt;h2&gt;
  
  
  Proactive Invalidation: The Cache That Doesn't Lie
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;invalidate_kb_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DELETE FROM response_cache WHERE CAST(:kb_id AS UUID) = ANY(knowledge_base_ids)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kb_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowcount&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Called automatically on every operation that changes KB content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# In faq_service.py — when creating, updating or importing FAQs
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_faq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;faq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FAQ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;knowledge_base_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;faq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;invalidate_kb_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Cache dies
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;faq&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same happens when uploading or reprocessing documents. Simple rule: &lt;strong&gt;if a KB's content changed, that KB's cache dies&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Auto-FAQ: Turn Frequent Questions into Real FAQs
&lt;/h2&gt;

&lt;p&gt;The system records queries with low confidence. When one appears multiple times, the admin can auto-generate a FAQ:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_faq_suggestion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unanswered_query_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;uq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UnansweredQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unanswered_query_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Dedup: skip if pending suggestion or similar FAQ (&amp;gt;= 0.85) exists
&lt;/span&gt;    &lt;span class="n"&gt;similar_faq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        SELECT 1 - (embedding &amp;lt;=&amp;gt; CAST(:embedding AS vector)) AS score
        FROM faqs WHERE knowledge_base_id = :kb_id AND is_active = true
        ORDER BY embedding &amp;lt;=&amp;gt; CAST(:embedding AS vector) LIMIT 1
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;faq_row&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;faq_row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# Similar FAQ already exists
&lt;/span&gt;
    &lt;span class="c1"&gt;# Search KB for context, then call LLM
&lt;/span&gt;    &lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;search_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generate_playground_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;uq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Answer ONLY with information from the context. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2-4 sentences. If insufficient info: NO_ANSWER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NO_ANSWER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;SuggestedFAQ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;knowledge_base_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;uq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;generated_answer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pending&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Batch generation prioritizes by frequency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;batch_generate_suggestions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        SELECT uq.id FROM unanswered_queries uq
        WHERE uq.tenant_id = :tid AND uq.resolved = false
          AND NOT EXISTS (
              SELECT 1 FROM suggested_faqs sf
              WHERE sf.source_query_id = uq.id AND sf.status = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pending&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;
          )
        ORDER BY uq.occurrence_count DESC LIMIT :lim
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Generate suggestions for each...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On approval, the real FAQ is created, cache is invalidated, and the query is marked as resolved. Closed loop.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ-Only Fallback: Degraded Service Without Cutoff
&lt;/h2&gt;

&lt;p&gt;Free plan: 50 AI queries/month. When exhausted, FAQs keep working:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_llm_budget_exhausted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Soft check — enables FAQ-only fallback, does NOT raise.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_get_plan_and_tenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get_monthly_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_messages_month&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages_month&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_messages_month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the main flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;faq_only_mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;is_llm_budget_exhausted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# FAQ matching always works
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;faq_match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;faq_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;faq_match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Free
&lt;/span&gt;
&lt;span class="c1"&gt;# No match + no budget → upgrade card
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;faq_only_mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upgrade_required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ve reached your AI query limit. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                   &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FAQs remain available at no cost.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;})}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Cost Metrics
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_get_cost_metrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Tokens by provider
&lt;/span&gt;    &lt;span class="c1"&gt;# ...configurable prices from settings...
&lt;/span&gt;    &lt;span class="n"&gt;groq_price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;settings_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cost.groq.price_per_1k_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.00027&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# FAQ savings estimation
&lt;/span&gt;    &lt;span class="n"&gt;avg_llm_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;total_tokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;llm_query_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;estimated_faq_tokens_saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;faq_count&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;avg_llm_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;estimated_faq_cost_saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;estimated_faq_tokens_saved&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;groq_price&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;by_provider&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;by_provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_estimated_cost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;faq_savings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queries_without_llm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;faq_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;estimated_tokens_saved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;estimated_faq_tokens_saved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;estimated_cost_saved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;estimated_faq_cost_saved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avg_cost_per_conversation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_cost&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;conv_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cache hit rate as the 7th stat card in the dashboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_cache_stats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rate_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        SELECT
            COUNT(*) FILTER (WHERE query_type = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cached&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;) AS cached_queries,
            COUNT(*) FILTER (WHERE query_type IN (&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;chat&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;widget&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cached&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)) AS total_queries
        FROM usage_logs WHERE tenant_id = :tid
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hit_rate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Full Flow in the Chat Endpoint
&lt;/h2&gt;

&lt;p&gt;To see how the 3 layers fit together, here's the real order in &lt;code&gt;chat.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 1. Budget check (soft — doesn't raise)
&lt;/span&gt;&lt;span class="n"&gt;faq_only_mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;is_llm_budget_exhausted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 2. FAQ match (always works, even in faq_only_mode)
&lt;/span&gt;&lt;span class="n"&gt;faq_match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;match_faq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;faq_match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;EventSourceResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;faq_event_generator&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;# $0
&lt;/span&gt;
&lt;span class="c1"&gt;# 3. Budget exhausted + no FAQ match → upgrade card
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;faq_only_mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upgrade_required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;

&lt;span class="c1"&gt;# 4. Semantic cache lookup
&lt;/span&gt;&lt;span class="n"&gt;query_embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embedding_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embed_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;effective_query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;config_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compute_config_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retrieval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detected_lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;cache_hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;lookup_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cache_hit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;EventSourceResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;cache_event_generator&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;# $0
&lt;/span&gt;
&lt;span class="c1"&gt;# 5. Full RAG pipeline (vector + BM25 + rerank + LLM)
# ... streaming response ...
&lt;/span&gt;
&lt;span class="c1"&gt;# 6. Fire-and-forget: store in cache for next time
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;low_confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;fire_and_forget_store_cache&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Real Numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FAQ threshold&lt;/td&gt;
&lt;td&gt;0.85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache threshold&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default cache TTL&lt;/td&gt;
&lt;td&gt;7 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FAQ match latency&lt;/td&gt;
&lt;td&gt;~15ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache lookup latency&lt;/td&gt;
&lt;td&gt;~20ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full pipeline latency&lt;/td&gt;
&lt;td&gt;~2-4s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;% queries resolved by FAQ&lt;/td&gt;
&lt;td&gt;15-25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;% queries resolved by cache&lt;/td&gt;
&lt;td&gt;10-20%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;% queries reaching the LLM&lt;/td&gt;
&lt;td&gt;55-75%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Estimated total savings&lt;/td&gt;
&lt;td&gt;30-45%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. FAQs Are the Best Investment
&lt;/h3&gt;

&lt;p&gt;Not sexy. It's a table with questions and answers. But every FAQ is a perfect answer served in 15ms at zero cost, forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Cache Threshold Must Be Very High
&lt;/h3&gt;

&lt;p&gt;At 0.90 I had false positives that served incorrect answers. 0.95 is the safe minimum.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Fire-and-Forget Is Mandatory for Cache Storage
&lt;/h3&gt;

&lt;p&gt;First attempt was synchronous. If the INSERT takes long, the last streaming token is delayed. Fire-and-forget eliminates that latency.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Proactive Invalidation Is Worth It
&lt;/h3&gt;

&lt;p&gt;It's tempting to let cache expire on its own (TTL). But the admin who uploads a document expects updated answers immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Auto-FAQ Closes the Loop
&lt;/h3&gt;

&lt;p&gt;Without auto-FAQ, knowledge gaps accumulate. With auto-FAQ, in 2 clicks the frequent question goes from gap to active FAQ.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. FAQ-Only Fallback &amp;gt; Cutting Off Service
&lt;/h3&gt;

&lt;p&gt;Free-tier users still ask questions that match FAQs. Reduces churn and provides real incentive to upgrade.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. LLM Prices Must Be Configurable
&lt;/h3&gt;

&lt;p&gt;Hardcoding &lt;code&gt;$0.00027/1K tokens&lt;/code&gt; is a trap. Prices change. Storing them in a settings table allows adjustments without a deploy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Optimizing RAG costs is a problem of &lt;strong&gt;avoiding unnecessary work&lt;/strong&gt;. The three layers (FAQ, cache, auto-FAQ) attack the same principle: if the answer already exists, don't pay to generate it again.&lt;/p&gt;

&lt;p&gt;The interesting part: they also improve the experience. FAQ match in 15ms vs 2-4s for the full pipeline. Cache hit in 20ms with the same quality. And pgvector was already in the stack — no need for Redis or Elasticsearch.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is the fifth article in the series. If it was useful, a like helps it reach more people. Questions about semantic cache or FAQ matching? Drop a comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>python</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Cache semántico y FAQ matching: cómo reduje un 40% el costo de LLM en mi motor RAG</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Tue, 07 Apr 2026 13:10:00 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/cache-semantico-y-faq-matching-como-reduje-un-40-el-costo-de-llm-en-mi-motor-rag-gg3</link>
      <guid>https://dev.to/martin_palopoli/cache-semantico-y-faq-matching-como-reduje-un-40-el-costo-de-llm-en-mi-motor-rag-gg3</guid>
      <description>&lt;p&gt;Cada query RAG cuesta dinero: embedding + tokens de LLM. Implementé tres capas de optimización que reducen el costo real un 30-45%: &lt;strong&gt;FAQ matching&lt;/strong&gt; (respuestas curadas a costo cero), &lt;strong&gt;cache semántico&lt;/strong&gt; (pgvector con similaridad &amp;gt;= 0.95), y &lt;strong&gt;auto-generación de FAQs&lt;/strong&gt; desde queries frecuentes sin respuesta. Todo con código de producción y un fallback que mantiene el servicio activo cuando el presupuesto de LLM se agota.&lt;/p&gt;




&lt;h2&gt;
  
  
  El problema: cada query cuesta dinero real
&lt;/h2&gt;

&lt;p&gt;En los artículos anteriores construí un pipeline RAG con búsqueda híbrida, reranking y streaming. Funciona bien. Pero en producción:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Query típica:  embedding + búsqueda + LLM (~800 tokens) ≈ $0.0003
1,000 queries/día × 30 días = 30,000 queries/mes
30,000 × $0.0003 = ~$9/mes por tenant
50 tenants = ~$450/mes solo en LLM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El insight clave: &lt;strong&gt;el 35-40% de las queries son repetidas o muy similares&lt;/strong&gt;. Cada una es dinero quemado.&lt;/p&gt;




&lt;h2&gt;
  
  
  Arquitectura de las 3 capas de ahorro
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Query del usuario
     │
     ▼
┌─────────────┐
│  FAQ Match   │──→ Si score ≥ 0.85: respuesta curada (costo LLM = $0)
└─────┬───────┘
      │ No match
      ▼
┌─────────────────┐
│ Semantic Cache   │──→ Si similaridad ≥ 0.95: respuesta cacheada ($0)
└─────┬───────────┘
      │ Cache miss
      ▼
┌─────────────────┐
│ Budget Check     │──→ Si presupuesto agotado: fallback FAQ-only
└─────┬───────────┘
      │ Budget OK
      ▼
  Pipeline RAG completo → fire-and-forget: guardar en cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Capa 1: FAQ Matching — la inversión más rentable
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;match_faq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;query_embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embedding_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embed_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;kb_ids_str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;kb_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;kb_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        SELECT id, question, answer, attachments,
               1 - (embedding &amp;lt;=&amp;gt; CAST(:embedding AS vector)) as score
        FROM faqs
        WHERE knowledge_base_id IN (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kb_ids_str&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)
          AND is_active = true AND embedding IS NOT NULL
        ORDER BY embedding &amp;lt;=&amp;gt; CAST(:embedding AS vector)
        LIMIT 1
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mappings&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="c1"&gt;# Increment hit_count atomically
&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FAQ&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FAQ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hit_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FAQ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hit_count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;faq_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El modelo usa un índice HNSW sobre el embedding para que la búsqueda sea ~15ms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FAQ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;faqs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;384&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;hit_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;server_default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;__table_args__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ix_faqs_embedding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;postgresql_using&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hnsw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;postgresql_ops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
              &lt;span class="n"&gt;postgresql_with&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;m&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ef_construction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Threshold tuning: de 0.75 a 0.85
&lt;/h3&gt;

&lt;p&gt;Empecé con 0.75. Demasiado bajo — obtuve false positives donde "¿Cómo configuro el widget?" matcheaba con "¿Qué es un widget?". Subí a 0.85 y los falsos positivos desaparecieron:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;FAQ almacenada&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;Match?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"como reseteo mi clave"&lt;/td&gt;
&lt;td&gt;"¿Cómo cambio mi contraseña?"&lt;/td&gt;
&lt;td&gt;0.91&lt;/td&gt;
&lt;td&gt;Si&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"aceptan visa?"&lt;/td&gt;
&lt;td&gt;"¿Qué medios de pago aceptan?"&lt;/td&gt;
&lt;td&gt;0.87&lt;/td&gt;
&lt;td&gt;Si&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"como configuro el widget"&lt;/td&gt;
&lt;td&gt;"¿Qué es un widget?"&lt;/td&gt;
&lt;td&gt;0.72&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cuando hay match, el pipeline completo no se ejecuta:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;faq_match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;faq_match&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;faq_match&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="c1"&gt;# FAQ matches are free — don't increment billing counters
&lt;/span&gt;    &lt;span class="nf"&gt;fire_and_forget_log_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;query_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;faq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;response_tokens=0&lt;/code&gt; y &lt;code&gt;query_type="faq"&lt;/code&gt;&lt;/strong&gt; — fundamental para las métricas de ahorro.&lt;/p&gt;




&lt;h2&gt;
  
  
  Capa 2: Cache Semántico — pgvector como cache inteligente
&lt;/h2&gt;

&lt;h3&gt;
  
  
  El concepto
&lt;/h3&gt;

&lt;p&gt;Si alguien pregunta "¿Cómo reseteo mi contraseña?" y hace 2 horas otro preguntó "¿Cómo puedo cambiar mi password?", la respuesta es la misma. El cache semántico detecta esa equivalencia comparando embeddings, no strings exactos.&lt;/p&gt;

&lt;p&gt;El modelo almacena el embedding del query, la respuesta, las fuentes, y un hash de la configuración RAG:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ResponseCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response_cache&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;query_embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;384&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;query_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;knowledge_base_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;as_uuid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;rag_config_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;hit_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;server_default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Config hash: scoping por configuración
&lt;/h3&gt;

&lt;p&gt;Si el admin cambia parámetros de RAG, las respuestas cacheadas ya no son válidas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;compute_config_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retrieval_config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;key_fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;candidate_k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;retrieval_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;candidate_k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rerank_top_n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;retrieval_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rerank_top_n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;top_k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;retrieval_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lambda_param&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;retrieval_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lambda_param&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_per_doc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;retrieval_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_per_doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bm25_weight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;retrieval_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bm25_weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;language&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;language&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;es&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_fields&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Lookup: similaridad &amp;gt;= 0.95
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;CACHE_SIMILARITY_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lookup_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rag_config_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        SELECT id, response_text, sources, confidence,
               1 - (query_embedding &amp;lt;=&amp;gt; CAST(:embedding AS vector)) AS similarity
        FROM response_cache
        WHERE tenant_id = :tid AND expires_at &amp;gt; now()
          AND rag_config_hash = :config_hash
          AND knowledge_base_ids @&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kb_array&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
        ORDER BY query_embedding &amp;lt;=&amp;gt; CAST(:embedding AS vector)
        LIMIT 1
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mappings&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;similarity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;CACHE_SIMILARITY_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE response_cache SET hit_count = hit_count + 1 WHERE id = :cid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response_text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response_text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;¿Por qué 0.95?&lt;/strong&gt; Con 0.90 tuve cache hits incorrectos:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query A&lt;/th&gt;
&lt;th&gt;Query B&lt;/th&gt;
&lt;th&gt;Similaridad&lt;/th&gt;
&lt;th&gt;¿Misma respuesta?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"Cómo exporto a CSV"&lt;/td&gt;
&lt;td&gt;"Cómo descargo datos en CSV"&lt;/td&gt;
&lt;td&gt;0.97&lt;/td&gt;
&lt;td&gt;Si&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Cómo configuro el webhook"&lt;/td&gt;
&lt;td&gt;"Cómo pruebo el webhook"&lt;/td&gt;
&lt;td&gt;0.92&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Cómo agrego usuarios"&lt;/td&gt;
&lt;td&gt;"Cómo elimino usuarios"&lt;/td&gt;
&lt;td&gt;0.91&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Fire-and-forget storage
&lt;/h3&gt;

&lt;p&gt;No podés bloquear el streaming SSE para guardar en cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fire_and_forget_store_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                 &lt;span class="n"&gt;kb_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rag_config_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                 &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl_hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;168&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_store&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;async_session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;store_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Failed to store cache: %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;loop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_running_loop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_store&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;  &lt;span class="c1"&gt;# No event loop (tests) — skip
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Y la condición para cachear:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retrieval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_enabled&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;kb_ids&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;low_confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;fire_and_forget_store_cache&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;not low_confidence&lt;/code&gt;&lt;/strong&gt;: no cacheamos respuestas malas.&lt;/p&gt;




&lt;h2&gt;
  
  
  Invalidación proactiva: el cache que no miente
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;invalidate_kb_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DELETE FROM response_cache WHERE CAST(:kb_id AS UUID) = ANY(knowledge_base_ids)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kb_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowcount&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Se llama automáticamente en cada operación que cambia contenido de una KB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# En faq_service.py — al crear, actualizar o importar FAQs
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_faq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;faq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FAQ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;knowledge_base_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;faq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;invalidate_kb_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Cache muere
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;faq&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lo mismo ocurre al subir o reprocesar documentos. Regla simple: &lt;strong&gt;si el contenido de una KB cambió, el cache de esa KB muere&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Auto-FAQ: convertir preguntas frecuentes en FAQs reales
&lt;/h2&gt;

&lt;p&gt;El sistema registra queries con baja confianza. Cuando una aparece múltiples veces, el admin puede auto-generar una FAQ:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_faq_suggestion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unanswered_query_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;uq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UnansweredQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unanswered_query_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Dedup: skip si ya hay sugerencia pendiente o FAQ similar (&amp;gt;= 0.85)
&lt;/span&gt;    &lt;span class="n"&gt;similar_faq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        SELECT 1 - (embedding &amp;lt;=&amp;gt; CAST(:embedding AS vector)) AS score
        FROM faqs WHERE knowledge_base_id = :kb_id AND is_active = true
        ORDER BY embedding &amp;lt;=&amp;gt; CAST(:embedding AS vector) LIMIT 1
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;faq_row&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;faq_row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# Similar FAQ already exists
&lt;/span&gt;
    &lt;span class="c1"&gt;# Search KB for context, then call LLM
&lt;/span&gt;    &lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;search_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generate_playground_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;uq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Responde SOLO con información del contexto. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2-4 oraciones. Si no hay info suficiente: NO_ANSWER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NO_ANSWER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;SuggestedFAQ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;knowledge_base_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;uq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;generated_answer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pending&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Batch generation prioriza por frecuencia:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;batch_generate_suggestions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        SELECT uq.id FROM unanswered_queries uq
        WHERE uq.tenant_id = :tid AND uq.resolved = false
          AND NOT EXISTS (
              SELECT 1 FROM suggested_faqs sf
              WHERE sf.source_query_id = uq.id AND sf.status = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pending&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;
          )
        ORDER BY uq.occurrence_count DESC LIMIT :lim
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Generate suggestions for each...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Al aprobar, se crea la FAQ real, se invalida el cache, y la query se marca como resuelta. Ciclo cerrado.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fallback FAQ-only: servicio degradado sin corte
&lt;/h2&gt;

&lt;p&gt;Plan Free: 50 queries de IA/mes. Cuando se agotan, las FAQs siguen activas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_llm_budget_exhausted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Soft check — enables FAQ-only fallback, does NOT raise.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_get_plan_and_tenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get_monthly_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_messages_month&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages_month&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_messages_month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En el flujo principal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;faq_only_mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;is_llm_budget_exhausted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# FAQ matching funciona siempre
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;faq_match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;faq_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;faq_match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Gratis
&lt;/span&gt;
&lt;span class="c1"&gt;# Sin match + sin presupuesto → upgrade card
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;faq_only_mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upgrade_required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Has alcanzado el límite de consultas de IA. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                   &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Las FAQs siguen disponibles sin costo.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;})}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Métricas de costo
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_get_cost_metrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Tokens by provider
&lt;/span&gt;    &lt;span class="c1"&gt;# ...configurable prices from settings...
&lt;/span&gt;    &lt;span class="n"&gt;groq_price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;settings_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cost.groq.price_per_1k_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.00027&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# FAQ savings estimation
&lt;/span&gt;    &lt;span class="n"&gt;avg_llm_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;total_tokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;llm_query_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;estimated_faq_tokens_saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;faq_count&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;avg_llm_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;estimated_faq_cost_saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;estimated_faq_tokens_saved&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;groq_price&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;by_provider&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;by_provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_estimated_cost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;faq_savings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queries_without_llm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;faq_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;estimated_tokens_saved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;estimated_faq_tokens_saved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;estimated_cost_saved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;estimated_faq_cost_saved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avg_cost_per_conversation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_cost&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;conv_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cache hit rate como 7ma stat card en el dashboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_cache_stats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rate_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        SELECT
            COUNT(*) FILTER (WHERE query_type = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cached&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;) AS cached_queries,
            COUNT(*) FILTER (WHERE query_type IN (&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;chat&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;widget&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cached&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)) AS total_queries
        FROM usage_logs WHERE tenant_id = :tid
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hit_rate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  El flujo completo en el endpoint de chat
&lt;/h2&gt;

&lt;p&gt;Para ver cómo encajan las 3 capas, este es el orden real en &lt;code&gt;chat.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 1. Budget check (soft — no lanza excepción)
&lt;/span&gt;&lt;span class="n"&gt;faq_only_mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;is_llm_budget_exhausted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 2. FAQ match (funciona siempre, incluso en faq_only_mode)
&lt;/span&gt;&lt;span class="n"&gt;faq_match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;match_faq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;faq_match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;EventSourceResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;faq_event_generator&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;# $0
&lt;/span&gt;
&lt;span class="c1"&gt;# 3. Budget exhausted + no FAQ match → upgrade card
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;faq_only_mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upgrade_required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;

&lt;span class="c1"&gt;# 4. Semantic cache lookup
&lt;/span&gt;&lt;span class="n"&gt;query_embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embedding_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embed_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;effective_query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;config_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compute_config_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rag_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retrieval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detected_lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;cache_hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;lookup_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kb_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cache_hit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;EventSourceResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;cache_event_generator&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;# $0
&lt;/span&gt;
&lt;span class="c1"&gt;# 5. Full RAG pipeline (vector + BM25 + rerank + LLM)
# ... streaming response ...
&lt;/span&gt;
&lt;span class="c1"&gt;# 6. Fire-and-forget: store in cache for next time
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;low_confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;fire_and_forget_store_cache&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Números reales
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Métrica&lt;/th&gt;
&lt;th&gt;Valor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Threshold FAQ&lt;/td&gt;
&lt;td&gt;0.85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Threshold cache&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTL cache default&lt;/td&gt;
&lt;td&gt;7 días&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latencia FAQ match&lt;/td&gt;
&lt;td&gt;~15ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latencia cache lookup&lt;/td&gt;
&lt;td&gt;~20ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latencia pipeline completo&lt;/td&gt;
&lt;td&gt;~2-4s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;% queries resueltas por FAQ&lt;/td&gt;
&lt;td&gt;15-25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;% queries resueltas por cache&lt;/td&gt;
&lt;td&gt;10-20%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;% queries que llegan al LLM&lt;/td&gt;
&lt;td&gt;55-75%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ahorro estimado total&lt;/td&gt;
&lt;td&gt;30-45%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Lecciones aprendidas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Las FAQs son la mejor inversión
&lt;/h3&gt;

&lt;p&gt;No es sexy. Es una tabla con preguntas y respuestas. Pero cada FAQ es una respuesta perfecta servida en 15ms a costo cero, para siempre.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. El threshold del cache debe ser muy alto
&lt;/h3&gt;

&lt;p&gt;Con 0.90 tuve falsos positivos que sirvieron respuestas incorrectas. 0.95 es el mínimo seguro.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Fire-and-forget es obligatorio para cache storage
&lt;/h3&gt;

&lt;p&gt;El primer intento fue síncrono. Si el INSERT tarda, el último token del streaming se demora. Fire-and-forget elimina esa latencia.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. La invalidación proactiva vale la pena
&lt;/h3&gt;

&lt;p&gt;Es tentador dejar que el cache expire solo (TTL). Pero el admin que sube un documento espera respuestas actualizadas inmediatamente.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Auto-FAQ cierra el ciclo
&lt;/h3&gt;

&lt;p&gt;Sin auto-FAQ, las knowledge gaps se acumulan. Con auto-FAQ, en 2 clicks la pregunta frecuente pasa de gap a FAQ activa.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Fallback FAQ-only &amp;gt; cortar el servicio
&lt;/h3&gt;

&lt;p&gt;Los usuarios free siguen haciendo preguntas que matchean FAQs. Reduce churn y da incentivo real para upgradear.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Los precios de LLM deben ser configurables
&lt;/h3&gt;

&lt;p&gt;Hardcodear &lt;code&gt;$0.00027/1K tokens&lt;/code&gt; es una trampa. Los precios cambian. Guardarlos en una tabla de settings permite ajustarlos sin deploy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusión
&lt;/h2&gt;

&lt;p&gt;Optimizar costos en RAG es un problema de &lt;strong&gt;evitar trabajo innecesario&lt;/strong&gt;. Las tres capas (FAQ, cache, auto-FAQ) atacan el mismo principio: si la respuesta ya existe, no pagues por generarla de nuevo.&lt;/p&gt;

&lt;p&gt;Lo interesante: también mejoran la experiencia. FAQ match en 15ms vs 2-4s del pipeline. Cache hit en 20ms con la misma calidad. Y pgvector ya estaba en el stack — no necesitás Redis ni Elasticsearch.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Este es el quinto artículo de la serie. Si te sirvió, un like ayuda a que llegue a más personas. ¿Tenés preguntas sobre cache semántico o FAQ matching? Dejá un comentario.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>python</category>
      <category>ai</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
