sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ e0e390c2829a2bb6d30b49193dc5ced05bd45ad2
scripts/test-atuq-archivo-semantico.py
History
Who touched what
raw
atuq §6.3: la búsqueda semántica, VERDE en la jaula — y la barra lateral tiene las dos preguntas
1#!/usr/bin/env python3
2"""¿Se le puede preguntar al archivo personal por lo que DECÍA? (SDD 26 §6.3, mitad semántica)
3
4 python3 scripts/test-atuq-archivo-semantico.py # con el modelo de la imagen
5 python3 scripts/test-atuq-archivo-semantico.py --negative-control # sin modelo: lo DICE
6
7── QUÉ SE EJERCITA ───────────────────────────────────────────────────────────────────────────
8El navegador archiva tres páginas de temas distintos —eso es el §6.3 literal, que ya existía— y
9después se le pregunta al host **desde dentro de la jaula y por el cable de verdad** (marco de 4
10bytes), con los paths de PRODUCCIÓN. La respuesta tiene que ordenar por parecido: la página del
11tema preguntado primero.
12
13Y se pregunta DOS cosas distintas, que ganan páginas distintas. Con una sola pregunta, un ranking
14constante —o uno que siempre devuelve la más reciente— pasaría el test sin haber entendido nada.
15
16── POR QUÉ EL HOST SE INVOCA A MANO Y NO POR LA EXTENSIÓN ────────────────────────────────────
17Porque así se comprueba lo que de verdad importa del archivo: **que quedó en disco y se consulta
18sin el navegador**. Es el mismo camino que usa `scripts/test-atuq-archivo.py` para la búsqueda
19literal.
20
21REQUISITOS: rootfs de `atuq-nested.sh`, y `atuq` + `puriy-costura` + `llama-cpp` +
22`ia-modelo-embeddings` sellados.
23"""
24import http.server
25import json
26import os
27import shutil
28import struct
29import subprocess
30import sys
31import tempfile
32import threading
33
34ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
35RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs")
36ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
37HAMMER = os.path.join(ROOT, "target/release/takana")
38STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
39MODELO_EN_IMAGEN = "/usr/share/takana/ia/embeddings.gguf"
40
41# Tres páginas de temas bien separados, y dos preguntas que NO comparten palabras con el texto de
42# su página: si compartieran, la literal también acertaría y esto no probaría nada semántico.
43PAGINAS = {
44 "/gato.html": ("El felino en el tejado",
45 "Un minino atigrado dormía sobre las tejas calientes toda la siesta."),
46 "/red.html": ("Cerrar la salida de una aplicación",
47 "Reglas de egress por cgroup, denegar por defecto y permitir sólo lo declarado."),
48 "/pan.html": ("Masa madre",
49 "Harina, agua y sal; fermentación lenta durante la noche y horno muy caliente."),
50}
51PREGUNTAS = [
52 ("¿dónde se echó a dormir el gato?", "/gato.html"),
53 ("cómo bloqueo internet a un programa", "/red.html"),
54]
55
56
57def fatal(msg):
58 print(f"✗ {msg}", file=sys.stderr)
59 raise SystemExit(1)
60
61
62def artefacto(receta, nombre, escotilla=None):
63 forzado = os.environ.get(escotilla) if escotilla else None
64 if forzado:
65 if not os.path.isdir(forzado):
66 fatal(f"{escotilla}={forzado} no existe")
67 print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
68 return forzado
69 r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
70 cwd=ROOT, capture_output=True, text=True)
71 if r.returncode != 0:
72 fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
73 h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
74 d = os.path.join(STORE, f"{h}-{nombre}")
75 if not os.path.isdir(d):
76 fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
77 if not os.listdir(d):
78 fatal(f"{nombre} b3:{h[:8]} está VACÍO: eso no es un artefacto, es un nombre")
79 return d
80
81
82class Servidor(http.server.SimpleHTTPRequestHandler):
83 def log_message(self, *a):
84 pass
85
86 def do_GET(self):
87 ruta = self.path.split("?")[0]
88 if ruta in PAGINAS:
89 titulo, texto = PAGINAS[ruta]
90 # Cada página lleva a la siguiente: tres cargas de PRIMER NIVEL con un solo arranque.
91 orden = list(PAGINAS)
92 i = orden.index(ruta)
93 siguiente = orden[i + 1] if i + 1 < len(orden) else "/fin.html"
94 datos = (f'<!doctype html><html><head><meta charset="utf-8"><title>{titulo}</title>'
95 f'<meta http-equiv="refresh" content="3;url={siguiente}">'
96 f"</head><body><h1>{titulo}</h1><p>{texto}</p></body></html>").encode()
97 elif ruta == "/ir.html":
98 # ⚠ La primera versión de esto metía las tres páginas en `<iframe>` y NO archivaba
99 # ninguna: el archivo ignora a propósito lo que no es el marco principal (un iframe de
100 # publicidad no es una página que alguien leyó). Ahora se ENCADENAN como navegación de
101 # verdad, que es lo que hace una persona.
102 datos = (b'<!doctype html><html><head><meta charset="utf-8">'
103 b'<meta http-equiv="refresh" content="1;url=/gato.html">'
104 # Sin texto visible: el archivo descarta lo que no tiene nada que buscar, así
105 # que esta página de tránsito no ensucia la evidencia con una entrada sin título.
106 b"</head><body></body></html>")
107 else:
108 # Igual que `/ir.html`: sin texto visible, para que el final de la cadena no se archive.
109 datos = b"<html><body></body></html>"
110 self.send_response(200)
111 self.send_header("Content-Type", "text/html; charset=utf-8")
112 self.send_header("Content-Length", str(len(datos)))
113 self.send_header("Cache-Control", "no-store")
114 self.end_headers()
115 self.wfile.write(datos)
116
117
118def puerto_libre():
119 import socket
120 s = socket.socket()
121 s.bind(("127.0.0.1", 0))
122 p = s.getsockname()[1]
123 s.close()
124 return p
125
126
127def marco(obj):
128 """El cable de native messaging: 4 bytes little-endian y el cuerpo."""
129 cuerpo = json.dumps(obj).encode()
130 return struct.pack("<I", len(cuerpo)) + cuerpo
131
132
133def correr(capas, salida, guion):
134 subprocess.run([
135 "bwrap",
136 "--overlay-src", ALPINE, "--overlay-src", RFS, *sum((["--overlay-src", c] for c in capas), []),
137 "--tmp-overlay", "/",
138 "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
139 "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
140 "--bind", salida, "/salida",
141 "--uid", "0", "--gid", "0",
142 "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
143 "--setenv", "XDG_RUNTIME_DIR", "/run",
144 "--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1",
145 "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
146 "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
147 "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
148 "--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
149 "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
150 "sh", "-c", guion,
151 ], check=False)
152
153
154def main():
155 negativo = "--negative-control" in sys.argv[1:]
156 if negativo:
157 print("== MODO CONTROL NEGATIVO: sin modelo de embeddings; tiene que DECIRLO")
158 if not os.path.isdir(RFS):
159 fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`")
160 atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
161 costura = artefacto("puriy-costura", "puriy-costura")
162 llama = artefacto("llama-cpp", "llama-cpp", "LLAMA_DIR")
163 modelo = artefacto("ia-modelo-embeddings", "ia-modelo-embeddings")
164 print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}"
165 f"\n {os.path.basename(llama)}\n {os.path.basename(modelo)}")
166
167 puerto = puerto_libre()
168 servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Servidor)
169 threading.Thread(target=servidor.serve_forever, daemon=True).start()
170
171 tmp = tempfile.mkdtemp(prefix="atuq-archivo-sem-")
172 salida = os.path.join(tmp, "salida")
173 os.makedirs(os.path.join(salida, "perfil"))
174 try:
175 with open(os.path.join(salida, "perfil", "user.js"), "w") as fh:
176 fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
177 fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
178
179 # Las preguntas se escriben ya enmarcadas: así el guión de adentro sólo tiene que
180 # volcarlas al binario, sin hacer aritmética de bytes en `sh`.
181 for i, (q, _) in enumerate(PREGUNTAS):
182 with open(os.path.join(salida, f"pregunta-{i}.bin"), "wb") as fh:
183 fh.write(marco({"verb": "archive.ask", "query": q}))
184
185 borrar = f"rm -f {MODELO_EN_IMAGEN}" if negativo else "true"
186 guion = f"""
187 mkdir -p /root /salida/hogar
188 [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
189 export HOME=/salida/hogar
190 {borrar}
191 timeout 90 /usr/bin/atuq --profile /salida/perfil --no-remote --headless \
192 'http://127.0.0.1:{puerto}/ir.html' > /salida/consola.log 2>&1
193 # Y ahora, SIN navegador: el archivo quedó en disco y se le pregunta por el cable.
194 E=/salida/hogar/.local/state/puriy-costura
195 for i in 0 1; do
196 /usr/bin/puriy-costura --state $E < /salida/pregunta-$i.bin > /salida/respuesta-$i.bin 2>/salida/err-$i.txt
197 done
198 exit 0
199 """
200 capas = [atuq, costura, llama] + ([] if negativo else [modelo])
201 # En el control negativo el modelo NO se monta: `rm -f` sobre una capa de sólo lectura
202 # borraría el fichero de la capa temporal, pero montar y borrar es contar dos veces.
203 correr(capas if not negativo else [atuq, costura, llama], salida, guion)
204
205 log = open(os.path.join(salida, "consola.log"), errors="replace").read()
206 base = next((l[len("IA BASE "):].strip() for l in log.splitlines()
207 if l.startswith("IA BASE ")), None)
208 lineas = [l[len("ARCHIVO "):] for l in log.splitlines() if l.startswith("ARCHIVO ")]
209 archivadas = [l for l in lineas if l.startswith("ARCHIVADA ")]
210 for l in archivadas:
211 print(" " + l)
212 if len(archivadas) < 3:
213 destino = os.path.join(ROOT, "work", "atuq-archivo-sem.log")
214 os.makedirs(os.path.dirname(destino), exist_ok=True)
215 shutil.copy(os.path.join(salida, "consola.log"), destino)
216 fatal(f"se archivaron {len(archivadas)} páginas de 3 — log en {destino}")
217
218 for i, (q, esperada) in enumerate(PREGUNTAS):
219 crudo = open(os.path.join(salida, f"respuesta-{i}.bin"), "rb").read()
220 if len(crudo) < 4:
221 err = open(os.path.join(salida, f"err-{i}.txt"), errors="replace").read()
222 fatal(f"el host no contestó a «{q}»: {err.strip()[:300]}")
223 n = struct.unpack("<I", crudo[:4])[0]
224 r = json.loads(crudo[4:4 + n])
225 if negativo:
226 if r.get("ok"):
227 fatal("sin modelo CONTESTÓ igual: eso es inventar un orden")
228 if "no hay modelo de embeddings" not in (r.get("error") or ""):
229 fatal(f"la causa no nombra lo que falta: {r.get('error')}")
230 print(f"\n RECHAZO {r['error']}")
231 print("\n✓ control negativo: sin modelo lo dice, nombrando lo que falta")
232 print("✓ y no inventa un orden, que es el único fallo inaceptable en una búsqueda")
233 return 0
234 if not r.get("ok"):
235 fatal(f"la pregunta «{q}» falló: {r.get('error')}")
236 m = r.get("matches") or []
237 if not m:
238 fatal(f"«{q}» no devolvió ninguna coincidencia sobre {r.get('archived')} páginas")
239 print(f"\n «{q}» → indexadas {r['indexed']}/{r['archived']}")
240 for c in m:
241 print(f" {c['score']:.4f} {c['page']['title']}")
242 if esperada not in m[0]["page"]["url"]:
243 fatal(f"«{q}» puso primera a {m[0]['page']['url']} y esperaba {esperada}"
244 " — el orden no sigue al SIGNIFICADO")
245
246 # ── Y LA BARRA LATERAL, que es por donde lo usa una persona ──────────────────────
247 # Lo de arriba mide el host; esto mide que la extensión llegue hasta él y muestre el
248 # orden. Sin esta mitad, la función existiría y no habría cómo usarla.
249 if not base:
250 fatal("la extensión de IA no dijo su URL base: no puedo abrir el panel")
251 q, esperada = PREGUNTAS[0]
252 panel = f"{base}panel.html?p={q.replace(' ', '%20').replace('¿', '%C2%BF')}"
253 guion2 = f"""
254 mkdir -p /root /salida/hogar
255 [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
256 export HOME=/salida/hogar
257 timeout 90 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{panel}' \
258 > /salida/panel.log 2>&1
259 exit 0
260 """
261 correr(capas, salida, guion2)
262 plog = open(os.path.join(salida, "panel.log"), errors="replace").read()
263 coincidencias = [l[len("IA COINCIDENCIA "):] for l in plog.splitlines()
264 if l.startswith("IA COINCIDENCIA ")]
265 print("\n por la barra lateral:")
266 for c in coincidencias[:4]:
267 print(" " + c)
268 if not coincidencias:
269 destino = os.path.join(ROOT, "work", "atuq-panel-sem.log")
270 os.makedirs(os.path.dirname(destino), exist_ok=True)
271 shutil.copy(os.path.join(salida, "panel.log"), destino)
272 fatal(f"el panel no mostró ninguna coincidencia — log en {destino}")
273 if esperada not in coincidencias[0]:
274 fatal(f"el panel puso primera a «{coincidencias[0]}» y esperaba {esperada}")
275
276 print("\n✓ el archivo contesta por PARECIDO: cada pregunta pone primera a su página")
277 print("✓ y son dos preguntas con ganadores distintos — un ranking constante no pasaría")
278 print("✓ todo sin navegador: el archivo quedó en disco y se consulta por el cable")
279 print("✓ y por la barra lateral también: la extensión llega al host y muestra el orden")
280 return 0
281 finally:
282 servidor.shutdown()
283 shutil.rmtree(tmp, ignore_errors=True)
284
285
286sys.exit(main())