atuq §6.3.ter: preguntarle al archivo por lo que DECÍA, no por las palabras exactas
El muro no era un daemon ni un LLM — y el propio comentario de la extensión lo decía mal, copiando lo que hace willay-rag. Medido: para ordenar por parecido no hace falta ningún LLM (eso es un coseno) y el «daemon de embeddings» resultó ser el mismo llama-server que ya levanta el chat, con otro modelo. La corrección quedó escrita donde estaba la afirmación. Del lado de la suite (tawasuyu 22527f7d1 y b4ecffea8): el protocolo de llama-server salió a `shared/foreign-llama` (regla 4 — vivía dentro de puriy-costura y ya tenía dos consumidores), el `Provider` es `rimay-verbo-llama` (regla 10 — la familia verbo YA es la abstracción de embeddings, y éste es su primer backend sin nube ni descargas), y el índice es `rimay-verbo-index::VectorIndex`. Acá: la receta del modelo (multilingual-e5-small, MIT), que se pinea en fp32 y la receta CUANTIZA a Q8_0 con nuestro llama-quantize — así la procedencia es de quien declara la licencia, la imagen se lleva 126 MB en vez de 476, y la transformación es nuestra y verificable. Más el guardián y la extensión, que ahora expone `archive.ask` al lado del `archive.search` literal: son dos preguntas distintas y conviven. ⚠ El guardián está escrito pero NO corrió todavía: `ia-modelo-embeddings` quedó en la cola del flock detrás del build de otro agente. Lo medido hasta acá es el modelo a mano (384 dimensiones, el pasaje correcto gana con y sin los prefijos de e5) y 45 tests en tawasuyu. La línea del SDD que lo dice se borra cuando dé verde.
This commit is contained in:
@@ -674,6 +674,54 @@ archiva lo privado» sin saber cuál de las dos cosas estaba pasando.
|
||||
por byte, porque `truncate` sobre medio multibyte entra en pánico y en un archivo personal el texto
|
||||
con acentos es el caso normal.
|
||||
|
||||
#### 6.3.ter La otra mitad: preguntarle al archivo por lo que DECÍA (2026-09-12)
|
||||
|
||||
Hasta acá el archivo se buscaba **literal**: todas las palabras tienen que aparecer. Ahora también
|
||||
se le puede preguntar por el significado (`archive.ask`), y las dos conviven porque son dos
|
||||
preguntas: «la página que decía `EADDRINUSE`» es literal y no necesita ningún modelo.
|
||||
|
||||
**El muro no era un daemon ni un LLM, y el propio código lo decía mal.** El comentario de la
|
||||
extensión afirmaba que el semántico «necesita un daemon de embeddings y un LLM de verdad», copiando
|
||||
lo que hace `willay-rag`. Medido: para ordenar por parecido **no hace falta ningún LLM** —eso es un
|
||||
coseno— y el «daemon de embeddings» resultó ser el mismo `llama-server` que ya levanta el chat, con
|
||||
otro modelo. La corrección quedó escrita donde estaba la afirmación.
|
||||
|
||||
**Lo que se construyó, y dónde vive cada cosa** — que es la parte que las reglas del repo deciden,
|
||||
no el gusto:
|
||||
|
||||
| pieza | dónde | por qué ahí |
|
||||
|---|---|---|
|
||||
| el protocolo de `llama-server` | `shared/foreign-llama` | **regla 4**: un dialecto ajeno entra por un puente. La primera versión vivía DENTRO de `puriy-costura::ia`, y cuando apareció el segundo consumidor —dos horas después— había dos clientes del mismo protocolo |
|
||||
| el `Provider` de embeddings | `rimay-verbo-llama` | **regla 10**: la familia `rimay-verbo-*` ya es la abstracción de embeddings de la suite. Es el primer backend suyo que no pide nube ni descarga nada al arrancar |
|
||||
| el índice y el coseno | `rimay-verbo-index::VectorIndex` | ya existían, con postcard y top-K. Escribir otro sería un segundo espacio vectorial sin dueño |
|
||||
| cuándo se embebe y qué se pregunta | `puriy-costura` | es lo nuestro |
|
||||
|
||||
**Tres decisiones con su porqué:**
|
||||
|
||||
- **los vectores se calculan al PREGUNTAR, no al archivar.** Archivar una página no puede quedarse
|
||||
esperando a que cargue un modelo, ni fallar porque la imagen no lo traiga. El precio es que la
|
||||
primera pregunta paga el atraso, y por eso la respuesta dice cuántas quedaron indexadas;
|
||||
- **es un SEGUNDO `llama-server`** (`--embedding --pooling mean`): llama.cpp carga un modelo por
|
||||
proceso y el de chat no sabe embeber. Ese `--pooling` no es un gusto — sin él el endpoint contesta
|
||||
un `400` que parece del cliente;
|
||||
- **sin umbral de puntaje.** Los cosenos de e5 dan ~0,9 entre frases sin relación: sus vectores viven
|
||||
en un cono estrecho, así que cualquier umbral «razonable» deja pasar todo o corta todo. Lo que
|
||||
significa algo es el **orden**, y de eso se ocupa el top-K.
|
||||
|
||||
El guardián (`scripts/test-atuq-archivo-semantico.py`) archiva tres páginas con el navegador y
|
||||
después le pregunta al host **sin navegador**, por el cable de 4 bytes y con los paths de producción
|
||||
— así se comprueba de paso lo que el §6.3 promete: que el archivo quedó en disco. Pregunta **dos**
|
||||
cosas con ganadores distintos, a propósito: con una sola, un ranking constante —o uno que devuelva
|
||||
siempre la más reciente— pasaría sin haber entendido nada. Y las preguntas **no comparten palabras**
|
||||
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.
|
||||
|
||||
⚠ **Al cerrar esta entrada el guardián está escrito pero NO ha corrido todavía** contra el artefacto
|
||||
sellado: `ia-modelo-embeddings` estaba en la cola del `flock` detrás del build de otro agente. Lo que
|
||||
sí está medido es todo lo de abajo del cable —35 tests de `puriy-costura`, 6 del puente, 4 del
|
||||
proveedor— y el modelo mismo, probado a mano antes de pinearlo. Esta línea se borra cuando el
|
||||
guardián dé verde.
|
||||
|
||||
### 6.4 `qullqa` con su advertencia puesta
|
||||
|
||||
Almacenamiento con negación plausible y modelo de adversario **escrito** — ningún navegador tiene
|
||||
|
||||
@@ -15,10 +15,18 @@
|
||||
// Y no se archiva lo que no tiene texto visible: guardar el HTML de un visor de PDF
|
||||
// llena el archivo de cosas que después no se encuentran.
|
||||
//
|
||||
// ⚠ LO QUE ESTO NO ES: preguntarle al historial en lenguaje natural. `archive.search`
|
||||
// es el registro LITERAL —todas las palabras tienen que aparecer—. El semántico es un
|
||||
// motor `rag-motor::RagMotor` como `willay-rag`, que necesita daemon de embeddings y
|
||||
// un LLM de verdad. Cuando exista, se enchufa del mismo lado y esto no cambia.
|
||||
// ── LAS DOS BÚSQUEDAS, Y POR QUÉ SON DOS ─────────────────────────────────────
|
||||
// `archive.search` es el registro LITERAL: todas las palabras tienen que aparecer, no
|
||||
// necesita modelo y contesta al instante. `archive.ask` (§6.3, desde 2026-09-12) ordena
|
||||
// por PARECIDO: embebe el archivo con el motor local y devuelve las páginas con su
|
||||
// puntaje. Son dos preguntas distintas —«la página que decía EADDRINUSE» es literal— y
|
||||
// por eso conviven.
|
||||
//
|
||||
// ⚠ Este comentario decía que el semántico «necesita un daemon de embeddings y un LLM
|
||||
// de verdad», copiando lo que hacía `willay-rag`. Era falso para este caso: no hace
|
||||
// falta ningún LLM para ordenar por parecido —eso es coseno— y el daemon resultó ser el
|
||||
// mismo `llama-server` que ya levanta el chat, con otro modelo. Quedó escrito acá el día
|
||||
// que se midió.
|
||||
|
||||
const HOST = "puriy_costura";
|
||||
|
||||
@@ -71,6 +79,13 @@ function alRecibir(r) {
|
||||
for (const p of r.pages) {
|
||||
di("HALLADA " + p.hash.slice(0, 12) + " " + p.url + " — " + p.title);
|
||||
}
|
||||
} else if (r.verb === "archive.ask" && r.matches) {
|
||||
di("PREGUNTA " + r.matches.length + " de " + r.archived + " (indexadas " + r.indexed + ")");
|
||||
for (const m of r.matches) {
|
||||
// El puntaje se muestra con el ORDEN, nunca como un porcentaje de acierto: los
|
||||
// cosenos de este modelo son ~0,9 entre frases sin relación.
|
||||
di("COINCIDENCIA " + m.score.toFixed(4) + " " + m.page.url + " — " + m.page.title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +125,8 @@ browser.runtime.onMessage.addListener(async (msg, emisor) => {
|
||||
browser.runtime.onMessageExternal.addListener(msg => {
|
||||
if (msg && msg.tipo === "buscar") {
|
||||
enviar({ verb: "archive.search", query: msg.query });
|
||||
} else if (msg && msg.tipo === "preguntar") {
|
||||
enviar({ verb: "archive.ask", query: msg.query });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# ia-modelo-embeddings — el modelo con el que se le pregunta al archivo personal en lenguaje natural
|
||||
# (SDD 26 §6.3, mitad semántica).
|
||||
#
|
||||
# ══ QUÉ ES, Y POR QUÉ ÉSTE ═════════════════════════════════════════════════════════════════════
|
||||
# **multilingual-e5-small**: 384 dimensiones, 118 M de parámetros. Elegido por una razón que se
|
||||
# puede medir: **el archivo personal va a estar en español**, y un modelo de embeddings entrenado
|
||||
# sólo en inglés buscaría peor justo donde más hay. Licencia MIT (del modelo base, `intfloat`).
|
||||
#
|
||||
# ══ POR QUÉ LA RECETA CUANTIZA EN VEZ DE PINEAR EL GGUF YA CUANTIZADO ══════════════════════════
|
||||
# La fuente pineada es el **fp32 (476 MB)** del único repo que declara licencia (MIT) y la receta lo
|
||||
# baja a **Q8_0 (126 MB)** con NUESTRO `llama-quantize` —el del corpus—. Así:
|
||||
# · la procedencia es de quien declara la licencia, no de un repo cualquiera con un q8 subido;
|
||||
# · la imagen se lleva 126 MB y no 476;
|
||||
# · y la cuantización es una transformación DETERMINISTA nuestra, verificable con
|
||||
# `verificar-repro.sh`, en vez de bytes de un tercero que nadie puede reproducir.
|
||||
#
|
||||
# ⚠ EL MODELO EXIGE PREFIJOS, Y NO ES UN DETALLE DE ESTILO. e5 se entrenó con `query: ` en la
|
||||
# consulta y `passage: ` en el documento. Medido con este mismo artefacto contra tres pasajes y una
|
||||
# pregunta: el pasaje correcto gana en los dos casos, pero con prefijos la distancia al segundo se
|
||||
# abre (0,9371 vs 0,9212 con prefijos; 0,9182 vs 0,8976 sin ellos). Los pone el consumidor
|
||||
# (`rimay-verbo-llama`), no esta receta, pero queda escrito acá porque el que pinea el modelo es el
|
||||
# que sabe qué convención tiene.
|
||||
#
|
||||
# ⚠ Y LOS COSENOS SON ALTOS EN ABSOLUTO (~0,9 entre frases sin relación). Es normal en e5: sus
|
||||
# vectores viven en un cono estrecho. Quien ponga un umbral absoluto («>0,8 es relevante») va a
|
||||
# aceptar todo; lo que vale es el ORDEN.
|
||||
#
|
||||
# ══ EL TARBALL Y LA PROCEDENCIA ════════════════════════════════════════════════════════════════
|
||||
# takana extrae toda fuente con `tar` y un `.gguf` pelado no es un tar ⇒ el objeto pineado es un tar
|
||||
# que lo envuelve, publicado en el mirror y servido por sha256 (ADR 0013: la URL no entra en
|
||||
# `hash_inputs`). Igual que `ia-modelo-chat` y `firefox-pgo-profile`.
|
||||
#
|
||||
# upstream https://huggingface.co/rodion-m/multilingual-e5-small-gguf (licencia MIT declarada)
|
||||
# fichero multilingual-e5-small-fp32.gguf
|
||||
# sha256 b3bf20a4ba914b630be1941dc3f5136cc99871d8fa2b402564a3b8752cf010c2 ← del GGUF, no del tar
|
||||
# modelo base https://huggingface.co/intfloat/multilingual-e5-small (MIT)
|
||||
name = "ia-modelo-embeddings"
|
||||
version = "multilingual-e5-small-q8_0"
|
||||
license = "MIT"
|
||||
|
||||
[source]
|
||||
tarball = "https://no-hay-upstream.invalid/takana/ia-modelo-embeddings.tar"
|
||||
sha256 = "886098319440f73a79aea2b3ced5a2e17a45f7d65792f6794ffa0c333583e696"
|
||||
strip_components = 0
|
||||
|
||||
[build]
|
||||
compiler = "zig-cc"
|
||||
|
||||
[build.phases]
|
||||
configure = "true"
|
||||
compile = "true"
|
||||
install = '''
|
||||
set -e
|
||||
mkdir -p /out/usr/share/takana/ia
|
||||
llama-quantize multilingual-e5-small-fp32.gguf /out/usr/share/takana/ia/embeddings.gguf Q8_0
|
||||
|
||||
M=/out/usr/share/takana/ia/embeddings.gguf
|
||||
|
||||
# ── GUARDIÁN: QUE SEA UN GGUF CUANTIZADO, NO UN FICHERO ─────────────────────────────────────
|
||||
# Dos modos de fallo callados: que `llama-quantize` escriba algo que no es un GGUF, y que escriba el
|
||||
# fp32 tal cual (o sea que la cuantización no haya pasado). Se comprueban el mágico y la BANDA de
|
||||
# tamaño: un Q8_0 de 118 M de parámetros pesa ~126 MB, así que 80–200 MB deja fuera tanto el
|
||||
# truncado como el fp32 de 476 MB.
|
||||
head -c 4 "$M" | grep -q GGUF || {
|
||||
echo "!! lo instalado no empieza con el mágico GGUF" >&2
|
||||
head -c 64 "$M" | od -c | head -3 >&2
|
||||
exit 1
|
||||
}
|
||||
n=$(stat -c%s "$M")
|
||||
test "$n" -gt 80000000 || { echo "!! el GGUF pesa $n bytes: está truncado" >&2; exit 1; }
|
||||
test "$n" -lt 200000000 || { echo "!! el GGUF pesa $n bytes: parece el fp32 sin cuantizar" >&2; exit 1; }
|
||||
echo "guardián: GGUF Q8_0 válido — $n bytes"
|
||||
'''
|
||||
|
||||
[deps]
|
||||
build = ["llama-cpp"]
|
||||
@@ -43,7 +43,7 @@ license = "MIT OR Apache-2.0"
|
||||
|
||||
[source]
|
||||
repo = "gitea@git.tawasuyu.net:tawasuyu/tawasuyu.git"
|
||||
commit = "fea0e89036fda8dcdd61191f3953a50e93b1e6a6"
|
||||
commit = "b4ecffea828a7514e7d397253715c83c1b9b5255"
|
||||
# ⚠ 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" }`.
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""¿Se le puede preguntar al archivo personal por lo que DECÍA? (SDD 26 §6.3, mitad semántica)
|
||||
|
||||
python3 scripts/test-atuq-archivo-semantico.py # con el modelo de la imagen
|
||||
python3 scripts/test-atuq-archivo-semantico.py --negative-control # sin modelo: lo DICE
|
||||
|
||||
── QUÉ SE EJERCITA ───────────────────────────────────────────────────────────────────────────
|
||||
El navegador archiva tres páginas de temas distintos —eso es el §6.3 literal, que ya existía— y
|
||||
después se le pregunta al host **desde dentro de la jaula y por el cable de verdad** (marco de 4
|
||||
bytes), con los paths de PRODUCCIÓN. La respuesta tiene que ordenar por parecido: la página del
|
||||
tema preguntado primero.
|
||||
|
||||
Y se pregunta DOS cosas distintas, que ganan páginas distintas. Con una sola pregunta, un ranking
|
||||
constante —o uno que siempre devuelve la más reciente— pasaría el test sin haber entendido nada.
|
||||
|
||||
── POR QUÉ EL HOST SE INVOCA A MANO Y NO POR LA EXTENSIÓN ────────────────────────────────────
|
||||
Porque así se comprueba lo que de verdad importa del archivo: **que quedó en disco y se consulta
|
||||
sin el navegador**. Es el mismo camino que usa `scripts/test-atuq-archivo.py` para la búsqueda
|
||||
literal.
|
||||
|
||||
REQUISITOS: rootfs de `atuq-nested.sh`, y `atuq` + `puriy-costura` + `llama-cpp` +
|
||||
`ia-modelo-embeddings` sellados.
|
||||
"""
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs")
|
||||
ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
|
||||
HAMMER = os.path.join(ROOT, "target/release/takana")
|
||||
STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
|
||||
MODELO_EN_IMAGEN = "/usr/share/takana/ia/embeddings.gguf"
|
||||
|
||||
# Tres páginas de temas bien separados, y dos preguntas que NO comparten palabras con el texto de
|
||||
# su página: si compartieran, la literal también acertaría y esto no probaría nada semántico.
|
||||
PAGINAS = {
|
||||
"/gato.html": ("El felino en el tejado",
|
||||
"Un minino atigrado dormía sobre las tejas calientes toda la siesta."),
|
||||
"/red.html": ("Cerrar la salida de una aplicación",
|
||||
"Reglas de egress por cgroup, denegar por defecto y permitir sólo lo declarado."),
|
||||
"/pan.html": ("Masa madre",
|
||||
"Harina, agua y sal; fermentación lenta durante la noche y horno muy caliente."),
|
||||
}
|
||||
PREGUNTAS = [
|
||||
("¿dónde se echó a dormir el gato?", "/gato.html"),
|
||||
("cómo bloqueo internet a un programa", "/red.html"),
|
||||
]
|
||||
|
||||
|
||||
def fatal(msg):
|
||||
print(f"✗ {msg}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def artefacto(receta, nombre, escotilla=None):
|
||||
forzado = os.environ.get(escotilla) if escotilla else None
|
||||
if forzado:
|
||||
if not os.path.isdir(forzado):
|
||||
fatal(f"{escotilla}={forzado} no existe")
|
||||
print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
|
||||
return forzado
|
||||
r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
|
||||
cwd=ROOT, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
|
||||
h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
|
||||
d = os.path.join(STORE, f"{h}-{nombre}")
|
||||
if not os.path.isdir(d):
|
||||
fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
|
||||
if not os.listdir(d):
|
||||
fatal(f"{nombre} b3:{h[:8]} está VACÍO: eso no es un artefacto, es un nombre")
|
||||
return d
|
||||
|
||||
|
||||
class Servidor(http.server.SimpleHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
ruta = self.path.split("?")[0]
|
||||
if ruta in PAGINAS:
|
||||
titulo, texto = PAGINAS[ruta]
|
||||
datos = (f'<!doctype html><html><head><meta charset="utf-8"><title>{titulo}</title>'
|
||||
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()
|
||||
else:
|
||||
datos = b"<html><body>nada</body></html>"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(datos)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(datos)
|
||||
|
||||
|
||||
def puerto_libre():
|
||||
import socket
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
p = s.getsockname()[1]
|
||||
s.close()
|
||||
return p
|
||||
|
||||
|
||||
def marco(obj):
|
||||
"""El cable de native messaging: 4 bytes little-endian y el cuerpo."""
|
||||
cuerpo = json.dumps(obj).encode()
|
||||
return struct.pack("<I", len(cuerpo)) + cuerpo
|
||||
|
||||
|
||||
def correr(capas, salida, guion):
|
||||
subprocess.run([
|
||||
"bwrap",
|
||||
"--overlay-src", ALPINE, "--overlay-src", RFS, *sum((["--overlay-src", c] for c in capas), []),
|
||||
"--tmp-overlay", "/",
|
||||
"--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
|
||||
"--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
|
||||
"--bind", salida, "/salida",
|
||||
"--uid", "0", "--gid", "0",
|
||||
"--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
|
||||
"--setenv", "XDG_RUNTIME_DIR", "/run",
|
||||
"--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1",
|
||||
"--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
|
||||
"--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
|
||||
"--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
|
||||
"--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
|
||||
"--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
|
||||
"sh", "-c", guion,
|
||||
], check=False)
|
||||
|
||||
|
||||
def main():
|
||||
negativo = "--negative-control" in sys.argv[1:]
|
||||
if negativo:
|
||||
print("== MODO CONTROL NEGATIVO: sin modelo de embeddings; tiene que DECIRLO")
|
||||
if not os.path.isdir(RFS):
|
||||
fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`")
|
||||
atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
|
||||
costura = artefacto("puriy-costura", "puriy-costura")
|
||||
llama = artefacto("llama-cpp", "llama-cpp", "LLAMA_DIR")
|
||||
modelo = artefacto("ia-modelo-embeddings", "ia-modelo-embeddings")
|
||||
print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}"
|
||||
f"\n {os.path.basename(llama)}\n {os.path.basename(modelo)}")
|
||||
|
||||
puerto = puerto_libre()
|
||||
servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Servidor)
|
||||
threading.Thread(target=servidor.serve_forever, daemon=True).start()
|
||||
|
||||
tmp = tempfile.mkdtemp(prefix="atuq-archivo-sem-")
|
||||
salida = os.path.join(tmp, "salida")
|
||||
os.makedirs(os.path.join(salida, "perfil"))
|
||||
try:
|
||||
with open(os.path.join(salida, "perfil", "user.js"), "w") as fh:
|
||||
fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
|
||||
fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
|
||||
|
||||
# Las preguntas se escriben ya enmarcadas: así el guión de adentro sólo tiene que
|
||||
# volcarlas al binario, sin hacer aritmética de bytes en `sh`.
|
||||
for i, (q, _) in enumerate(PREGUNTAS):
|
||||
with open(os.path.join(salida, f"pregunta-{i}.bin"), "wb") as fh:
|
||||
fh.write(marco({"verb": "archive.ask", "query": q}))
|
||||
|
||||
borrar = f"rm -f {MODELO_EN_IMAGEN}" if negativo else "true"
|
||||
guion = f"""
|
||||
mkdir -p /root /salida/hogar
|
||||
[ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
|
||||
export HOME=/salida/hogar
|
||||
{borrar}
|
||||
timeout 90 /usr/bin/atuq --profile /salida/perfil --no-remote --headless \
|
||||
'http://127.0.0.1:{puerto}/ir.html' > /salida/consola.log 2>&1
|
||||
# Y ahora, SIN navegador: el archivo quedó en disco y se le pregunta por el cable.
|
||||
E=/salida/hogar/.local/state/puriy-costura
|
||||
for i in 0 1; do
|
||||
/usr/bin/puriy-costura --state $E < /salida/pregunta-$i.bin > /salida/respuesta-$i.bin 2>/salida/err-$i.txt
|
||||
done
|
||||
exit 0
|
||||
"""
|
||||
capas = [atuq, costura, llama] + ([] if negativo else [modelo])
|
||||
# En el control negativo el modelo NO se monta: `rm -f` sobre una capa de sólo lectura
|
||||
# borraría el fichero de la capa temporal, pero montar y borrar es contar dos veces.
|
||||
correr(capas if not negativo else [atuq, costura, llama], salida, guion)
|
||||
|
||||
log = open(os.path.join(salida, "consola.log"), errors="replace").read()
|
||||
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:
|
||||
print(" " + l)
|
||||
if len(archivadas) < 3:
|
||||
destino = os.path.join(ROOT, "work", "atuq-archivo-sem.log")
|
||||
os.makedirs(os.path.dirname(destino), exist_ok=True)
|
||||
shutil.copy(os.path.join(salida, "consola.log"), destino)
|
||||
fatal(f"se archivaron {len(archivadas)} páginas de 3 — log en {destino}")
|
||||
|
||||
for i, (q, esperada) in enumerate(PREGUNTAS):
|
||||
crudo = open(os.path.join(salida, f"respuesta-{i}.bin"), "rb").read()
|
||||
if len(crudo) < 4:
|
||||
err = open(os.path.join(salida, f"err-{i}.txt"), errors="replace").read()
|
||||
fatal(f"el host no contestó a «{q}»: {err.strip()[:300]}")
|
||||
n = struct.unpack("<I", crudo[:4])[0]
|
||||
r = json.loads(crudo[4:4 + n])
|
||||
if negativo:
|
||||
if r.get("ok"):
|
||||
fatal("sin modelo CONTESTÓ igual: eso es inventar un orden")
|
||||
if "no hay modelo de embeddings" not in (r.get("error") or ""):
|
||||
fatal(f"la causa no nombra lo que falta: {r.get('error')}")
|
||||
print(f"\n RECHAZO {r['error']}")
|
||||
print("\n✓ control negativo: sin modelo lo dice, nombrando lo que falta")
|
||||
print("✓ y no inventa un orden, que es el único fallo inaceptable en una búsqueda")
|
||||
return 0
|
||||
if not r.get("ok"):
|
||||
fatal(f"la pregunta «{q}» falló: {r.get('error')}")
|
||||
m = r.get("matches") or []
|
||||
if not m:
|
||||
fatal(f"«{q}» no devolvió ninguna coincidencia sobre {r.get('archived')} páginas")
|
||||
print(f"\n «{q}» → indexadas {r['indexed']}/{r['archived']}")
|
||||
for c in m:
|
||||
print(f" {c['score']:.4f} {c['page']['title']}")
|
||||
if esperada not in m[0]["page"]["url"]:
|
||||
fatal(f"«{q}» puso primera a {m[0]['page']['url']} y esperaba {esperada}"
|
||||
" — el orden no sigue al SIGNIFICADO")
|
||||
|
||||
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")
|
||||
return 0
|
||||
finally:
|
||||
servidor.shutdown()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user