gioser

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…

TOML 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ main scripts/test-atuq-archivo-semantico.py Python · 286 lines · 14.2 KB 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 4a2801331 · Sergio ·

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())