atuq §6.3: la búsqueda semántica, VERDE en la jaula — y la barra lateral tiene las dos preguntas

Los cuatro guardianes pasan sobre los artefactos sellados (atuq bfc14c92, puriy-costura 3f31233e,
ia-modelo-embeddings 2c0c4258):

  semántico    0.6252 gato / 0.2471 red / 0.0973 pan  → y la otra pregunta gana la otra página
  control      «no hay modelo de embeddings en …» y NINGÚN orden inventado
  ia           el modelo contesta y el motor se va con el navegador (0 vivos)
  foco         los tres estados, y el estado intacto tras la sesión

La barra lateral ahora tiene dos botones: «Al modelo» (genera texto) y «A mis páginas» (ordena lo que
ya leíste). No se mezclan a propósito — una inventa y la otra recuerda, y juntas sería imposible
saber cuál contestó. Los resultados van EN ORDEN y sin porcentaje: el puntaje es un coseno y leerlo
como «85 % de acierto» sería inventarle un significado.

⚠ Y el guardián nació midiendo NADA: metía las tres páginas en `<iframe>` y archivó cero, porque el
§6.3 ignora lo que no es marco principal. Encadenadas como navegación de verdad entran las tres; y
las páginas de tránsito van sin texto visible para que el archivo las descarte y la evidencia no
liste coincidencias sin título.

Dos cosas más del camino, las dos medidas: el `cp` del install buscaba el nombre de upstream y el tar
—nuestro— lleva el fichero con nombre corto; y el build murió dos veces por DISCO LLENO (0 bytes en
/mnt/vvv), no por el lock. `scripts/poda-fuentes.sh --horas 6` liberó 4,6 G, que es exactamente para
lo que existe.
This commit is contained in:
Sergio
2026-09-12 04:25:35 +00:00
parent 097579546f
commit 4a28013316
9 changed files with 156 additions and 27 deletions
+17 -8
View File
@@ -716,18 +716,27 @@ siempre la más reciente— pasaría sin haber entendido nada. Y las preguntas *
con el texto de su página; si las compartieran, la búsqueda literal también acertaría y esto no
probaría nada semántico.
**Y lo que todavía NO corrió** (2026-09-12): ese guardián espera el artefacto sellado del modelo, y
el build lleva 45 min en la cola del `flock` detrás de otro agente. Lo que sí está medido con el
modelo de verdad es el camino completo `archive.add` → índice → `archive.ask` **fuera de la jaula**,
contra el binario del host: 3/3, con los puntajes de abajo. Esta línea se borra cuando el guardián de
la jaula dé verde.
**MEDIDO en la jaula, sobre los artefactos sellados** (`atuq bfc14c92`, `puriy-costura 3f31233e`,
`ia-modelo-embeddings 2c0c4258`):
```
«¿dónde se echó a dormir el gato?» 0.6466 gato · 0.2194 red · 0.1463 pan
«cómo bloqueo internet a un programa» 0.4082 red · 0.1408 pan · 0.0954 gato
«qué lleva el pan» 0.2393 pan · 0.2028 gato · 0.1331 red
ARCHIVADA … /gato.html ARCHIVADA … /red.html ARCHIVADA … /pan.html
«¿dónde se echó a dormir el gato?» 0.6252 gato · 0.2471 red · 0.0973 pan
«cómo bloqueo internet a un programa» 0.4039 red · 0.1398 pan · 0.1066 gato
por la barra lateral 0.6252 /gato.html …
```
Y el control negativo: sin modelo, `no hay modelo de embeddings en /usr/share/takana/ia/embeddings.gguf:
esta imagen no trae ninguno pineado`**y ningún orden inventado**, que es el único fallo inaceptable
en una búsqueda.
**El guardián nació midiendo nada, y lo dijo el propio arnés.** La primera versión metía las tres
páginas en `<iframe>` para archivarlas en una sola carga, y archivó **cero**: el §6.3 ignora a
propósito lo que no es el marco principal —un iframe de publicidad no es una página que alguien
leyó—. Encadenadas como navegación de verdad (cada página lleva a la siguiente con un `meta
refresh`), las tres entran. De paso: las dos páginas de tránsito van **sin texto visible**, así el
archivo las descarta y la evidencia no lista dos coincidencias sin título.
#### 6.3.quater El modelo que elegimos NO SERVÍA, y el síntoma no se parecía a la causa (2026-09-12)
Esto es la unidad de trabajo más caras de esta tanda y vale escribirla entera, porque la clase de
+7 -1
View File
@@ -252,7 +252,13 @@ sobrevive y medio giga de modelo en RAM no.
El puerto nativo vive en el **fondo**, no en la página del panel: si viviera en la página, cerrar la
barra lateral se llevaría el proceso del host y el modelo cargado con ella. `panel.html?q=…` abre el
panel con la pregunta ya hecha.
panel con la pregunta ya hecha, y `?p=…` la manda **a tus páginas** en vez de al modelo.
**Dos preguntas, dos botones, y no se mezclan a propósito.** «Al modelo» genera texto; «A mis
páginas» ordena por parecido lo que ya leíste (§6.3, `archive.ask`) y muestra los títulos **en
orden**, sin porcentaje: el puntaje es un coseno y leerlo como «85 % de acierto» sería inventarle un
significado. Presentar las dos juntas haría imposible saber cuál contestó — una inventa y la otra
recuerda.
Cuando algo falta, el panel **muestra la causa** («no hay modelo en …», «no hay motor de inferencia
en …»). Una IA que ante un fallo contesta algo genérico es peor que una que no contesta: el usuario
+19 -9
View File
@@ -36,11 +36,17 @@ function conectar() {
const pendiente = r && r.id ? enVuelo.get(r.id) : null;
if (!pendiente) return;
enVuelo.delete(r.id);
if (r.ok) {
if (!r.ok) {
di("ERROR " + (r.error || "sin causa"));
} else if (r.verb === "archive.ask") {
di("ARCHIVO " + (r.matches || []).length + " de " + r.archived +
" (indexadas " + r.indexed + ")");
for (const m of r.matches || []) {
di("COINCIDENCIA " + m.score.toFixed(4) + " " + m.page.url);
}
} else {
di("RESPUESTA tokens=" + (r.tokens === undefined ? "?" : r.tokens) +
" modelo=" + (r.model || "?") + " texto=" + JSON.stringify(r.answer || ""));
} else {
di("ERROR " + (r.error || "sin causa"));
}
pendiente(r);
});
@@ -59,26 +65,30 @@ function conectar() {
return true;
}
function preguntar(texto) {
// El mismo puerto sirve las dos preguntas del panel, y no es casualidad: las dos las
// contesta el MISMO host con el MISMO motor local — una con el modelo de chat y la otra
// con el de embeddings sobre lo que ya leíste (§6.3).
function pedir(verbo, campo, texto) {
if (!puerto && !conectar()) {
return Promise.resolve({ ok: false, error: "no hay host nativo en esta imagen" });
}
const id = siguiente++;
di("PREGUNTA id=" + id + " " + JSON.stringify(texto));
di("PIDE " + verbo + " id=" + id + " " + JSON.stringify(texto));
return new Promise((resolver) => {
enVuelo.set(id, resolver);
try {
puerto.postMessage({ id, verb: "ai.ask", prompt: texto });
puerto.postMessage({ id, verb: verbo, [campo]: texto });
} catch (e) {
enVuelo.delete(id);
resolver({ ok: false, error: "no pude enviar la pregunta: " + e });
resolver({ ok: false, error: "no pude enviar: " + e });
}
});
}
browser.runtime.onMessage.addListener((msg) => {
if (!msg || msg.tipo !== "preguntar" || typeof msg.texto !== "string") return;
return preguntar(msg.texto);
if (!msg || typeof msg.texto !== "string") return;
if (msg.tipo === "preguntar") return pedir("ai.ask", "prompt", msg.texto);
if (msg.tipo === "archivo") return pedir("archive.ask", "query", msg.texto);
});
di("LISTA v1 el modelo corre en esta máquina; no sale nada a la red");
+3
View File
@@ -8,3 +8,6 @@ main { display: flex; flex-direction: column; height: 100vh; box-sizing: border-
.turno.error { color: #b3261e; }
form { display: flex; gap: 6px; }
textarea { flex: 1; resize: vertical; font: inherit; }
.botones { display: flex; flex-direction: column; gap: 4px; }
.fuente { display: block; margin-top: 2px; opacity: .8; }
.fuente a { color: inherit; }
+4 -1
View File
@@ -7,7 +7,10 @@
<div id="hilo"></div>
<form id="forma">
<textarea id="texto" rows="3" placeholder="Preguntá algo…"></textarea>
<button id="enviar" type="submit">Preguntar</button>
<div class="botones">
<button id="enviar" type="submit">Al modelo</button>
<button id="archivo" type="button">A mis páginas</button>
</div>
</form>
</main>
<script src="panel.js"></script>
+51 -1
View File
@@ -38,6 +38,44 @@ async function preguntar(texto) {
esperando.textContent = r.answer;
}
// Las páginas ya leídas (§6.3): la misma caja, otra pregunta. No se mezcla con la del
// modelo a propósito — una INVENTA texto y la otra devuelve lo que vos leíste, y presentarlas
// juntas haría imposible saber cuál de las dos contestó.
async function buscarEnMisPaginas(texto) {
if (!texto.trim()) return;
agregar("yo", texto);
const esperando = agregar("", "buscando en lo que leíste…");
let r;
try {
r = await browser.runtime.sendMessage({ tipo: "archivo", texto });
} catch (e) {
r = { ok: false, error: String(e) };
}
if (!r || !r.ok) {
esperando.className = "turno error";
esperando.textContent = (r && r.error) || "el host no contestó";
return;
}
const m = r.matches || [];
if (!m.length) {
esperando.textContent = `no encontré nada entre tus ${r.archived} páginas`;
return;
}
// Se muestran EN ORDEN y sin porcentaje: el puntaje es un coseno y leerlo como
// «85 % de acierto» sería inventarle un significado que no tiene.
esperando.textContent = `de tus ${r.archived} páginas, en orden de parecido:`;
for (const c of m.slice(0, 8)) {
const d = document.createElement("div");
d.className = "fuente";
const a = document.createElement("a");
a.href = c.page.url;
a.textContent = c.page.title || c.page.url;
a.title = c.page.url;
d.appendChild(a);
esperando.appendChild(d);
}
}
document.getElementById("forma").addEventListener("submit", (ev) => {
ev.preventDefault();
const caja = document.getElementById("texto");
@@ -46,5 +84,17 @@ document.getElementById("forma").addEventListener("submit", (ev) => {
preguntar(texto);
});
const q = new URLSearchParams(location.search).get("q");
document.getElementById("archivo").addEventListener("click", () => {
const caja = document.getElementById("texto");
const texto = caja.value;
caja.value = "";
buscarEnMisPaginas(texto);
});
// `?q=` pregunta al modelo; `?p=` busca en las páginas. Las dos son la puerta por la que
// entran las preguntas que no se tipean acá (y las que usan los guardianes).
const params = new URLSearchParams(location.search);
const q = params.get("q");
if (q) preguntar(q);
const pag = params.get("p");
if (pag) buscarEnMisPaginas(pag);
+5 -1
View File
@@ -65,7 +65,11 @@ compile = "true"
install = '''
set -e
mkdir -p /out/usr/share/takana/ia
cp Qwen3-Embedding-0.6B-Q8_0.gguf /out/usr/share/takana/ia/embeddings.gguf
# ⚠ El fichero DENTRO del tar se llama `qwen3-emb-q8.gguf` y no como en upstream: el tar lo
# armamos nosotros y quedó con el nombre corto con el que se bajó. Se deja así —renombrarlo
# significaría reempaquetar y volver a subir 610 MB— pero queda dicho acá, porque el bloque de
# procedencia de arriba sí nombra el fichero de upstream y las dos cosas tienen que cuadrar.
cp qwen3-emb-q8.gguf /out/usr/share/takana/ia/embeddings.gguf
M=/out/usr/share/takana/ia/embeddings.gguf
# ── GUARDIÁN 1: QUE SEA UN GGUF ──────────────────────────────────────────────────────────────
+1 -1
View File
@@ -43,7 +43,7 @@ license = "MIT OR Apache-2.0"
[source]
repo = "gitea@git.tawasuyu.net:tawasuyu/tawasuyu.git"
commit = "8415e5d12a879026a7518fb2f626bce1bcdac00b"
commit = "43f9e09f476f159635c5b9ea209aeb854b92606c"
# ⚠ SIN ESTO EL BUILD MUERE EN UN SITIO QUE NO NOMBRA LA CAUSA — medido acá el 2026-09-10.
# tawasuyu COMMITEA su propio `vendor/`: adentro tiene un `smithay` parcheado a mano (tearing/flip
# async para el compositor), y lo enchufa con `[patch.crates-io] smithay = { path = "vendor/smithay" }`.
+49 -5
View File
@@ -87,15 +87,26 @@ class Servidor(http.server.SimpleHTTPRequestHandler):
ruta = self.path.split("?")[0]
if ruta in PAGINAS:
titulo, texto = PAGINAS[ruta]
# Cada página lleva a la siguiente: tres cargas de PRIMER NIVEL con un solo arranque.
orden = list(PAGINAS)
i = orden.index(ruta)
siguiente = orden[i + 1] if i + 1 < len(orden) else "/fin.html"
datos = (f'<!doctype html><html><head><meta charset="utf-8"><title>{titulo}</title>'
f'<meta http-equiv="refresh" content="3;url={siguiente}">'
f"</head><body><h1>{titulo}</h1><p>{texto}</p></body></html>").encode()
elif ruta == "/ir.html":
# Una página que pasea por las tres: el guión de contenido archiva cada una al cargar.
saltos = "".join(f'<iframe src="{r}" width="10" height="10"></iframe>' for r in PAGINAS)
datos = (f'<!doctype html><html><head><meta charset="utf-8"></head><body>{saltos}'
"</body></html>").encode()
# ⚠ La primera versión de esto metía las tres páginas en `<iframe>` y NO archivaba
# ninguna: el archivo ignora a propósito lo que no es el marco principal (un iframe de
# publicidad no es una página que alguien leyó). Ahora se ENCADENAN como navegación de
# verdad, que es lo que hace una persona.
datos = (b'<!doctype html><html><head><meta charset="utf-8">'
b'<meta http-equiv="refresh" content="1;url=/gato.html">'
# Sin texto visible: el archivo descarta lo que no tiene nada que buscar, así
# que esta página de tránsito no ensucia la evidencia con una entrada sin título.
b"</head><body></body></html>")
else:
datos = b"<html><body>nada</body></html>"
# Igual que `/ir.html`: sin texto visible, para que el final de la cadena no se archive.
datos = b"<html><body></body></html>"
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(datos)))
@@ -192,6 +203,8 @@ def main():
correr(capas if not negativo else [atuq, costura, llama], salida, guion)
log = open(os.path.join(salida, "consola.log"), errors="replace").read()
base = next((l[len("IA BASE "):].strip() for l in log.splitlines()
if l.startswith("IA BASE ")), None)
lineas = [l[len("ARCHIVO "):] for l in log.splitlines() if l.startswith("ARCHIVO ")]
archivadas = [l for l in lineas if l.startswith("ARCHIVADA ")]
for l in archivadas:
@@ -230,9 +243,40 @@ def main():
fatal(f"«{q}» puso primera a {m[0]['page']['url']} y esperaba {esperada}"
" — el orden no sigue al SIGNIFICADO")
# ── Y LA BARRA LATERAL, que es por donde lo usa una persona ──────────────────────
# Lo de arriba mide el host; esto mide que la extensión llegue hasta él y muestre el
# orden. Sin esta mitad, la función existiría y no habría cómo usarla.
if not base:
fatal("la extensión de IA no dijo su URL base: no puedo abrir el panel")
q, esperada = PREGUNTAS[0]
panel = f"{base}panel.html?p={q.replace(' ', '%20').replace('¿', '%C2%BF')}"
guion2 = f"""
mkdir -p /root /salida/hogar
[ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
export HOME=/salida/hogar
timeout 90 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{panel}' \
> /salida/panel.log 2>&1
exit 0
"""
correr(capas, salida, guion2)
plog = open(os.path.join(salida, "panel.log"), errors="replace").read()
coincidencias = [l[len("IA COINCIDENCIA "):] for l in plog.splitlines()
if l.startswith("IA COINCIDENCIA ")]
print("\n por la barra lateral:")
for c in coincidencias[:4]:
print(" " + c)
if not coincidencias:
destino = os.path.join(ROOT, "work", "atuq-panel-sem.log")
os.makedirs(os.path.dirname(destino), exist_ok=True)
shutil.copy(os.path.join(salida, "panel.log"), destino)
fatal(f"el panel no mostró ninguna coincidencia — log en {destino}")
if esperada not in coincidencias[0]:
fatal(f"el panel puso primera a «{coincidencias[0]}» y esperaba {esperada}")
print("\n✓ el archivo contesta por PARECIDO: cada pregunta pone primera a su página")
print("✓ y son dos preguntas con ganadores distintos — un ranking constante no pasaría")
print("✓ todo sin navegador: el archivo quedó en disco y se consulta por el cable")
print("✓ y por la barra lateral también: la extensión llega al host y muestra el orden")
return 0
finally:
servidor.shutdown()