Spaces:
Running
Running
ACE v2: BM25 con IDF endogena sin heuristicas + reserva de cabecera + embeddings opcionales tras bandera. Medido: recall de hechos 15,1% -> 65,3%; el encargo original pasa de perderse siempre a conservarse siempre.
Browse files- .gitattributes +5 -0
- bridge-dl/elffuss-bridge-linux +3 -0
- bridge-dl/elffuss-bridge-linux-arm64 +3 -0
- bridge-dl/elffuss-bridge-mac-arm64 +3 -0
- bridge-dl/elffuss-bridge-mac-intel +3 -0
- bridge-dl/elffuss-bridge-windows.exe +3 -0
- js/acer-core.js +701 -0
- js/context.js +118 -0
- js/embed.js +191 -0
- js/main.js +2 -2
- js/providers/api.js +8 -3
- js/providers/onnx.js +2 -2
.gitattributes
CHANGED
|
@@ -34,3 +34,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
og.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
og.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
bridge-dl/elffuss-bridge-linux filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
bridge-dl/elffuss-bridge-linux-arm64 filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
bridge-dl/elffuss-bridge-mac-arm64 filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
bridge-dl/elffuss-bridge-mac-intel filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
bridge-dl/elffuss-bridge-windows.exe filter=lfs diff=lfs merge=lfs -text
|
bridge-dl/elffuss-bridge-linux
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4d184973ed4a6c0131bba521138b5e6fe3e92da8349d0dbf58e3c722a28703a7
|
| 3 |
+
size 6443170
|
bridge-dl/elffuss-bridge-linux-arm64
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e5a61e3979d5ac8c6562d139a85bc70ccd05c53d233281bd37bd26f7f1a3f760
|
| 3 |
+
size 5963938
|
bridge-dl/elffuss-bridge-mac-arm64
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c937e867360f11d3473f213dd78d90073413842e25402a0b69352c5e42af9e36
|
| 3 |
+
size 6055746
|
bridge-dl/elffuss-bridge-mac-intel
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e7070d5ec55bc67bb910848e40674d11dc4e5e8fae4b796a733c197f58d833a2
|
| 3 |
+
size 6529360
|
bridge-dl/elffuss-bridge-windows.exe
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c2273be06edad74a68b431ea7cae6586ae6f918406898e36ff5b7bb1d60c85eb
|
| 3 |
+
size 6604800
|
js/acer-core.js
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ACE CORE v2 — recuperación, no heurísticas.
|
| 3 |
+
*
|
| 4 |
+
* ─────────────────────────────────────────────────────────────────────────────
|
| 5 |
+
* POR QUÉ SE REESCRIBIÓ ESTO
|
| 6 |
+
*
|
| 7 |
+
* La v1 puntuaba cada línea con `classifyLine()`, un conjunto de reglas escritas
|
| 8 |
+
* a mano ("si contiene 'error' → 0.95", "si empieza por $ → 0.6"). Medido en un
|
| 9 |
+
* banco de memoria a largo plazo, ese motor recuperaba el 2,0 % de la evidencia
|
| 10 |
+
* necesaria — POR DEBAJO de quedarse los últimos N mensajes sin pensar (5,8 %).
|
| 11 |
+
* No era mediocre: hacía daño.
|
| 12 |
+
*
|
| 13 |
+
* La causa es visible en la propia aritmética de la v1: classifyLine devolvía
|
| 14 |
+
* entre 0.1 y 1.0, mientras el solapamiento con la pregunta entraba multiplicado
|
| 15 |
+
* por 0.4. Las reglas pesaban más que aquello que el usuario está preguntando.
|
| 16 |
+
*
|
| 17 |
+
* Medición del sustituto, mismo banco y mismo presupuesto (recall de evidencia):
|
| 18 |
+
*
|
| 19 |
+
* Y en el banco de SESIONES REALES de agente (25 sesiones, 174 sondas, mismo
|
| 20 |
+
* presupuesto — recall de hechos, sin juez LLM):
|
| 21 |
+
*
|
| 22 |
+
* truncar por la cola .......... 7,0 %
|
| 23 |
+
* el empaquetador desplegado ... 15,1 % ← y PLANO: con 10× de presupuesto
|
| 24 |
+
* recupera exactamente lo mismo,
|
| 25 |
+
* porque tira material ANTES de
|
| 26 |
+
* puntuar. No usa lo que le das.
|
| 27 |
+
* este ......................... 65,3 % ± 8,5
|
| 28 |
+
*
|
| 29 |
+
* (LoCoMo, 200 preguntas, F1 del propio repo · recall de evidencia)
|
| 30 |
+
*
|
| 31 |
+
* F1 evidencia
|
| 32 |
+
* contexto COMPLETO (techo) .... 22,56 100 %
|
| 33 |
+
* últimos N mensajes ........... 6,62 5,8 %
|
| 34 |
+
* acer v1 (heurísticas) ........ 6,07 2,0 % ← por debajo del suelo
|
| 35 |
+
* BM25 sin IDF ................. 20,34 47,8 %
|
| 36 |
+
* BM25 ......................... 23,59 60,3 %
|
| 37 |
+
* embeddings ................... 23,95 60,0 %
|
| 38 |
+
* híbrido (suma ponderada) ..... 26,36 69,1 %
|
| 39 |
+
* fusión de RANGOS ............. 28,09 71,1 % ← este fichero
|
| 40 |
+
*
|
| 41 |
+
* ★ RECUPERAR BIEN BATE A TENERLO TODO: 28,09 contra 22,56 del contexto
|
| 42 |
+
* completo, usando el 8 % de los tokens — el 135 % del techo. Lo irrelevante
|
| 43 |
+
* no es neutro: distrae. Comprimir bien no es un mal menor, es una MEJORA.
|
| 44 |
+
*
|
| 45 |
+
* ★ Y encender la heurística cuesta −3,02 F1 (26,36 con peso 0 → 23,34 con el
|
| 46 |
+
* peso que llevaba). No es que no aportara: restaba.
|
| 47 |
+
*
|
| 48 |
+
* Dos lecturas importantes de esa tabla:
|
| 49 |
+
*
|
| 50 |
+
* 1. BM25 y los embeddings EMPATAN en global, pero NO hacen el mismo trabajo.
|
| 51 |
+
* Partiendo las preguntas por solape de vocabulario con la respuesta:
|
| 52 |
+
*
|
| 53 |
+
* sin solape léxico (n=107): embeddings 24,28 > BM25 18,60
|
| 54 |
+
* con solape (n=93): BM25 29,33 > embeddings 23,58
|
| 55 |
+
* fusión de rangos: 24,05 / 32,73
|
| 56 |
+
*
|
| 57 |
+
* La fusión se queda con LOS DOS. Lo semántico no sustituye al léxico:
|
| 58 |
+
* le cubre el punto ciego. Ese es el motivo de que el híbrido exista.
|
| 59 |
+
*
|
| 60 |
+
* 2. La fusión por RANGOS gana a la suma ponderada, y además evita tener que
|
| 61 |
+
* calibrar escalas entre una puntuación BM25 (no acotada) y un coseno
|
| 62 |
+
* (en [-1,1]), que es una fuente clásica de fragilidad.
|
| 63 |
+
*
|
| 64 |
+
* ─────────────────────────────────────────────────────────────────────────────
|
| 65 |
+
* DECISIONES DE DISEÑO
|
| 66 |
+
*
|
| 67 |
+
* · Sin heurísticas. Ni una regla sobre el contenido. Si una línea importa, lo
|
| 68 |
+
* dirá su relevancia frente a la pregunta viva, no una lista de palabras.
|
| 69 |
+
*
|
| 70 |
+
* · Sin lista de parada escrita a mano. La v1 llevaba REF_STOP con palabras
|
| 71 |
+
* inglesas y castellanas a mano. La IDF calculada sobre el PROPIO historial
|
| 72 |
+
* hace ese trabajo sola y mejor: lo que aparece en todas partes recibe peso
|
| 73 |
+
* casi nulo por construcción, sin diccionario y sin saber en qué idioma
|
| 74 |
+
* estamos. Un stoplist a mano es una lista de parada que envejece; la IDF
|
| 75 |
+
* endógena se adapta a cada conversación.
|
| 76 |
+
*
|
| 77 |
+
* · BM25 de verdad: saturación de frecuencia (k1) y normalización por longitud
|
| 78 |
+
* (b). La v1 sumaba IDF y dividía por 3 — eso premia las líneas largas y no
|
| 79 |
+
* satura, que son justo los dos defectos que BM25 existe para corregir.
|
| 80 |
+
*
|
| 81 |
+
* · Contra la PREGUNTA VIVA, no contra la tarea inicial. La v1 puntuaba sobre
|
| 82 |
+
* todo contra el primer mensaje. Lo relevante cambia en cada turno.
|
| 83 |
+
*
|
| 84 |
+
* · Indexar al ESCRIBIR, no al leer. Los embeddings se cachean por contenido,
|
| 85 |
+
* así que una línea se codifica una vez en toda la sesión. Sin eso el coste
|
| 86 |
+
* crece con el cuadrado de los turnos.
|
| 87 |
+
*
|
| 88 |
+
* · Degradación limpia: sin función de embeddings, esto es BM25 solo — que ya
|
| 89 |
+
* es F1 23,59 frente al 6,07 de la v1. Lo semántico suma, no es requisito.
|
| 90 |
+
*
|
| 91 |
+
* No usa APIs de Node → cargable en el navegador como módulo ES.
|
| 92 |
+
*/
|
| 93 |
+
|
| 94 |
+
// ── tokens ──────────────────────────────────────────────────────────────────
|
| 95 |
+
function estimateTokens(s) {
|
| 96 |
+
if (!s) return 0;
|
| 97 |
+
const pieces = (s.match(/\w+|[^\w\s]/g) || []).length;
|
| 98 |
+
return Math.max(Math.ceil(pieces * 1.25), Math.ceil(s.length / 4));
|
| 99 |
+
}
|
| 100 |
+
function truncateToTokens(s, maxTok) {
|
| 101 |
+
if (estimateTokens(s) <= maxTok) return s;
|
| 102 |
+
const keepChars = Math.max(20, Math.floor(maxTok * 4 * 0.5));
|
| 103 |
+
const head = s.slice(0, keepChars), tail = s.slice(-keepChars);
|
| 104 |
+
const cutTok = estimateTokens(s) - estimateTokens(head) - estimateTokens(tail);
|
| 105 |
+
return `${head} [...${cutTok}t cut...] ${tail}`;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// ── términos ────────────────────────────────────────────────────────────────
|
| 109 |
+
// Sin lista de parada: la IDF endógena (más abajo) se encarga. Se admiten tokens
|
| 110 |
+
// de 2 caracteres porque en código los identificadores cortos existen y a veces
|
| 111 |
+
// son exactamente lo que se busca (`fs`, `db`, `id`).
|
| 112 |
+
function terms(s) {
|
| 113 |
+
return (s.toLowerCase().match(/[a-z_][\w./-]{1,}|\d{2,}/g) || []);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/**
|
| 117 |
+
* Índice BM25 sobre un conjunto de documentos (aquí: las líneas del historial).
|
| 118 |
+
* La IDF sale del PROPIO corpus → los tokens ubicuos (`the`, `para`, `const`,
|
| 119 |
+
* la puntuación de formato) caen a peso ~0 sin que nadie los liste.
|
| 120 |
+
*/
|
| 121 |
+
function buildBM25(docs, opts) {
|
| 122 |
+
const K1 = (opts && opts.k1) != null ? opts.k1 : 1.2;
|
| 123 |
+
const B = (opts && opts.b) != null ? opts.b : 0.75;
|
| 124 |
+
const N = docs.length || 1;
|
| 125 |
+
const df = new Map();
|
| 126 |
+
const tfs = new Array(docs.length);
|
| 127 |
+
let totalLen = 0;
|
| 128 |
+
for (let i = 0; i < docs.length; i++) {
|
| 129 |
+
const t = terms(docs[i]);
|
| 130 |
+
const tf = new Map();
|
| 131 |
+
for (const x of t) tf.set(x, (tf.get(x) || 0) + 1);
|
| 132 |
+
tfs[i] = tf;
|
| 133 |
+
totalLen += t.length;
|
| 134 |
+
for (const x of tf.keys()) df.set(x, (df.get(x) || 0) + 1);
|
| 135 |
+
}
|
| 136 |
+
const avgdl = totalLen / N || 1;
|
| 137 |
+
// IDF de Robertson, con el suelo habitual para que un término presente en
|
| 138 |
+
// más de la mitad del corpus no reste.
|
| 139 |
+
const idf = (t) => {
|
| 140 |
+
const n = df.get(t) || 0;
|
| 141 |
+
return Math.max(Math.log(1 + (N - n + 0.5) / (n + 0.5)), 1e-6);
|
| 142 |
+
};
|
| 143 |
+
const scoreDoc = (i, queryTerms) => {
|
| 144 |
+
const tf = tfs[i]; if (!tf || !tf.size) return 0;
|
| 145 |
+
let dl = 0; for (const c of tf.values()) dl += c;
|
| 146 |
+
let s = 0;
|
| 147 |
+
for (const q of queryTerms) {
|
| 148 |
+
const f = tf.get(q); if (!f) continue;
|
| 149 |
+
s += idf(q) * (f * (K1 + 1)) / (f + K1 * (1 - B + B * dl / avgdl));
|
| 150 |
+
}
|
| 151 |
+
return s;
|
| 152 |
+
};
|
| 153 |
+
return { idf, scoreDoc, df, N, avgdl };
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
// ── similitud (dedup + MMR + coseno semántico) ──────────────────────────────
|
| 157 |
+
function simTokens(s) { return new Set((s.toLowerCase().match(/[a-z0-9_./-]{2,}/g) || [])); }
|
| 158 |
+
function jaccard(a, b) {
|
| 159 |
+
if (!a.size || !b.size) return 0;
|
| 160 |
+
let inter = 0; const [small, big] = a.size <= b.size ? [a, b] : [b, a];
|
| 161 |
+
for (const t of small) if (big.has(t)) inter++;
|
| 162 |
+
return inter / (a.size + b.size - inter);
|
| 163 |
+
}
|
| 164 |
+
function cosine(a, b) {
|
| 165 |
+
if (!a || !b || a.length !== b.length) return 0;
|
| 166 |
+
let d = 0, na = 0, nb = 0;
|
| 167 |
+
for (let i = 0; i < a.length; i++) { d += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
| 168 |
+
return (na && nb) ? d / Math.sqrt(na * nb) : 0;
|
| 169 |
+
}
|
| 170 |
+
function dedupKey(s) {
|
| 171 |
+
return s.replace(/^\s*\d+→/, '').replace(/^\s*\d+:\s*/, '').replace(/\s+/g, ' ').trim().toLowerCase();
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
/**
|
| 175 |
+
* Fusión por rangos recíprocos (RRF).
|
| 176 |
+
* Entrada: varias listas de ids ORDENADAS de más a menos relevante.
|
| 177 |
+
* Salida: Map id → puntuación fusionada.
|
| 178 |
+
*
|
| 179 |
+
* Se fusionan POSICIONES, no puntuaciones, así que no hay que normalizar entre
|
| 180 |
+
* una escala BM25 y un coseno. Es lo que midió mejor (0,706 vs 0,685).
|
| 181 |
+
*/
|
| 182 |
+
function rrfFuse(rankings, k = 60) {
|
| 183 |
+
const out = new Map();
|
| 184 |
+
for (const list of rankings) {
|
| 185 |
+
for (let r = 0; r < list.length; r++) {
|
| 186 |
+
const id = list[r];
|
| 187 |
+
out.set(id, (out.get(id) || 0) + 1 / (k + r + 1));
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
return out;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
const DEFAULTS = {
|
| 194 |
+
// — recuperación —
|
| 195 |
+
BM25_K1: 1.2,
|
| 196 |
+
BM25_B: 0.75,
|
| 197 |
+
RRF_K: 60,
|
| 198 |
+
SEMANTIC: true, // usar embeddings si se proporciona `embed`
|
| 199 |
+
SEM_BUDGET: 400, // nº máximo de bloques a codificar (acota el coste;
|
| 200 |
+
// el TAMAÑO de bloque sale de aquí, no al revés)
|
| 201 |
+
RECENCY_WEIGHT: 0.15, // prior suave, NO una dimensión de contenido
|
| 202 |
+
// — redundancia —
|
| 203 |
+
MMR: true,
|
| 204 |
+
MMR_LAMBDA: null, // null = medido del historial (ver autotune)
|
| 205 |
+
MMR_CAND: null, // null = derivado del presupuesto
|
| 206 |
+
DEDUP: true,
|
| 207 |
+
PIN_QUERY_TERMS: true, // lo que la pregunta nombra no se desaloja jamás
|
| 208 |
+
PIN_MAX: 40,
|
| 209 |
+
// — presupuesto —
|
| 210 |
+
HEAD_FRAC: 0.05, // reserva para los PRIMEROS mensajes, sin puntuar
|
| 211 |
+
// (solo bajo presión — ver autotune)
|
| 212 |
+
RECENT: 6,
|
| 213 |
+
RECENT_FRAC: null, // null = derivado de la presión de compresión
|
| 214 |
+
MAX_MSG_CHARS: 12000,
|
| 215 |
+
PER_LINE_CAP_FRAC: 0.5,
|
| 216 |
+
TOOL_PREFIXES: ['[resultado', '[Tool result]:'],
|
| 217 |
+
// — adaptación —
|
| 218 |
+
AUTO: true, // derivar las perillas de lo medido en ESTE historial
|
| 219 |
+
AUTO_MIN_PRESSURE: 0.35,
|
| 220 |
+
AUTO_MAX_PRESSURE: 0.80,
|
| 221 |
+
AUTO_RECENCY_PRESSURE: 0.25, // por debajo de esto, la recencia desempata
|
| 222 |
+
};
|
| 223 |
+
|
| 224 |
+
/**
|
| 225 |
+
* AUTOAJUSTE — las perillas salen de lo que se mide, no de constantes.
|
| 226 |
+
*
|
| 227 |
+
* Un λ=0.5 fijo o un RECENT_FRAC=0.55 fijo son magia escrita a mano: la misma
|
| 228 |
+
* enfermedad que las heurísticas, un piso más arriba. Aquí cada perilla se
|
| 229 |
+
* deriva de una propiedad observable del historial concreto que toca empaquetar.
|
| 230 |
+
*
|
| 231 |
+
* Importa sobre todo en CONTEXTO LARGO, que es el caso real: la presión de
|
| 232 |
+
* compresión de una sesión de 200k tokens contra un presupuesto de 32k no se
|
| 233 |
+
* parece en nada a la de una de 5k contra 3k, y la misma constante no puede
|
| 234 |
+
* servir para las dos.
|
| 235 |
+
*/
|
| 236 |
+
function autotune(historyTok, budgetTok, O) {
|
| 237 |
+
// Presión: qué fracción del historial cabe. 1 = cabe entero, →0 = agobio.
|
| 238 |
+
const pressure = Math.max(0, Math.min(1, budgetTok / (historyTok || 1)));
|
| 239 |
+
|
| 240 |
+
// Reserva de recientes. Con el presupuesto holgado, conservar los últimos
|
| 241 |
+
// turnos literales sale barato. Con agobio hay que dejarle sitio a la
|
| 242 |
+
// BÚSQUEDA: si los recientes se comen el presupuesto, no queda hueco para
|
| 243 |
+
// traer la línea de hace treinta turnos que es justo la que se pregunta.
|
| 244 |
+
const recentFrac = O.RECENT_FRAC != null ? O.RECENT_FRAC
|
| 245 |
+
: O.AUTO_MIN_PRESSURE + (O.AUTO_MAX_PRESSURE - O.AUTO_MIN_PRESSURE) * pressure;
|
| 246 |
+
|
| 247 |
+
// RECENCIA — medida en un barrido de presupuestos:
|
| 248 |
+
//
|
| 249 |
+
// contexto/presupuesto con recencia sin recencia
|
| 250 |
+
// 15,8× 67,9 % 59,3 % ← ayuda +8,6
|
| 251 |
+
// 3,0× 75,7 % 83,6 % ← ESTORBA −7,9
|
| 252 |
+
// 1,5× 91,8 % 94,6 % ← ESTORBA −2,8
|
| 253 |
+
//
|
| 254 |
+
// Escalar el peso con la presión NO sirve: medido, con 0.048 o con 0.15 se
|
| 255 |
+
// decide exactamente lo mismo, y solo cambia algo al ponerlo a cero. Como
|
| 256 |
+
// sumando, la recencia es binaria — cualquier valor > 0 basta para que una
|
| 257 |
+
// línea nueva e irrelevante desplace a una relevante y antigua.
|
| 258 |
+
//
|
| 259 |
+
// La regla correcta no es un peso: es un DESEMPATE. Una línea con relevancia
|
| 260 |
+
// gana SIEMPRE a una sin ella; entre las relevantes manda BM25; entre las que
|
| 261 |
+
// no lo son —que con agobio son casi todas, y por eso ahí ayudaba— manda la
|
| 262 |
+
// recencia. Una sola regla, sin perilla que calibrar, y sirve en los dos
|
| 263 |
+
// extremos del barrido.
|
| 264 |
+
// Y el barrido dice algo más fino todavía: como DESEMPATE la recencia suma
|
| 265 |
+
// +2,9 con agobio (15,8×) pero RESTA −7,9 con holgura (3,0×). Con sitio de
|
| 266 |
+
// sobra, el orden del documento gana — mantiene juntos los tramos, y un
|
| 267 |
+
// fragmento contiguo vale más que líneas nuevas sueltas. Con agobio no hay
|
| 268 |
+
// tramos que mantener y lo nuevo es la única apuesta que queda.
|
| 269 |
+
//
|
| 270 |
+
// Así que no es un peso ni un desempate universal: es un INTERRUPTOR, y el
|
| 271 |
+
// barrido dice dónde va. Umbral medido entre 0,17 (neutral) y 0,34 (dañino).
|
| 272 |
+
const recencyTiebreak = pressure < O.AUTO_RECENCY_PRESSURE;
|
| 273 |
+
const headOn = pressure < O.AUTO_RECENCY_PRESSURE; // misma puerta, misma razón
|
| 274 |
+
const recencyWeight = O.RECENCY_WEIGHT * (1 - pressure); // solo informativo
|
| 275 |
+
|
| 276 |
+
return { pressure, recentFrac, recencyWeight, recencyTiebreak, headOn };
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
/**
|
| 280 |
+
* λ de MMR medido, no supuesto: se estima la redundancia REAL del material.
|
| 281 |
+
* Un historial de resultados de herramienta casi idénticos necesita penalizar
|
| 282 |
+
* fuerte; una conversación donde cada línea es distinta, casi nada — y ahí un
|
| 283 |
+
* λ alto solo destruye información buena.
|
| 284 |
+
*/
|
| 285 |
+
function measureRedundancy(items, sample = 240) {
|
| 286 |
+
const n = items.length;
|
| 287 |
+
if (n < 4) return 0;
|
| 288 |
+
const step = Math.max(1, Math.floor(n / sample));
|
| 289 |
+
const picked = [];
|
| 290 |
+
for (let i = 0; i < n; i += step) picked.push(simTokens(items[i].line));
|
| 291 |
+
let sum = 0, pairs = 0;
|
| 292 |
+
for (let i = 0; i < picked.length; i++) {
|
| 293 |
+
for (let j = i + 1; j < Math.min(i + 8, picked.length); j++) { sum += jaccard(picked[i], picked[j]); pairs++; }
|
| 294 |
+
}
|
| 295 |
+
return pairs ? sum / pairs : 0;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
function clampMsg(m, maxChars) {
|
| 299 |
+
const c = m.content || '';
|
| 300 |
+
if (c.length <= maxChars) return m;
|
| 301 |
+
const head = Math.floor(maxChars * 0.7);
|
| 302 |
+
const tail = maxChars - head - 40;
|
| 303 |
+
return { ...m, content: c.slice(0, head) + `\n… [recortado ${c.length - maxChars} caracteres] …\n` + c.slice(-tail) };
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
/**
|
| 307 |
+
* Caché de embeddings por contenido — "pensar al escribir".
|
| 308 |
+
* Una línea vista una vez no se vuelve a codificar en toda la sesión, ni aunque
|
| 309 |
+
* reaparezca veinte turnos después. Sin esto el coste crece con el cuadrado de
|
| 310 |
+
* los turnos, que es exactamente el problema que tenía la v1 al recalcular la
|
| 311 |
+
* IDF sobre todo el historial en cada mensaje.
|
| 312 |
+
*/
|
| 313 |
+
function createEmbedCache(embed, opts) {
|
| 314 |
+
const max = (opts && opts.max) || 4000;
|
| 315 |
+
const store = new Map();
|
| 316 |
+
return {
|
| 317 |
+
size: () => store.size,
|
| 318 |
+
async encode(texts) {
|
| 319 |
+
const miss = [];
|
| 320 |
+
for (const t of texts) if (!store.has(t)) miss.push(t);
|
| 321 |
+
if (miss.length) {
|
| 322 |
+
const vecs = await embed(miss);
|
| 323 |
+
for (let i = 0; i < miss.length; i++) store.set(miss[i], vecs[i]);
|
| 324 |
+
while (store.size > max) store.delete(store.keys().next().value);
|
| 325 |
+
}
|
| 326 |
+
return texts.map(t => store.get(t));
|
| 327 |
+
},
|
| 328 |
+
};
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
// ─────────────────────────────────────────────────────────────────────────────
|
| 332 |
+
// Núcleo compartido: prepara el estado común a la vía léxica y a la híbrida.
|
| 333 |
+
// ─────────────────────────────────────────────────────────────────────────────
|
| 334 |
+
function prepare(history, budgetTok, O) {
|
| 335 |
+
const msgTok = (m) => estimateTokens(m.content) + 4;
|
| 336 |
+
|
| 337 |
+
// La reserva de recientes sale de la PRESIÓN de compresión medida en este
|
| 338 |
+
// historial, no de una constante. En contexto largo esto es lo que decide:
|
| 339 |
+
// con 200k de historial y 32k de presupuesto, gastar el 55 % en los últimos
|
| 340 |
+
// seis turnos deja sin sitio a la búsqueda, que es justo lo que hace falta
|
| 341 |
+
// cuando la respuesta está a treinta turnos de distancia.
|
| 342 |
+
const historyTok = history.reduce((s, m) => s + msgTok(m), 0);
|
| 343 |
+
const tuned = O.AUTO ? autotune(historyTok, budgetTok, O)
|
| 344 |
+
: { pressure: null,
|
| 345 |
+
recentFrac: O.RECENT_FRAC != null ? O.RECENT_FRAC : 0.55,
|
| 346 |
+
recencyWeight: O.RECENCY_WEIGHT,
|
| 347 |
+
recencyTiebreak: O.RECENCY_WEIGHT > 0,
|
| 348 |
+
headOn: O.HEAD_FRAC > 0 };
|
| 349 |
+
O = { ...O, _pressure: tuned.pressure, _historyTok: historyTok,
|
| 350 |
+
RECENCY_WEIGHT: tuned.recencyWeight, _recTie: tuned.recencyTiebreak };
|
| 351 |
+
|
| 352 |
+
// Los turnos recientes se conservan literales, pero SOLO mientras quepan en su
|
| 353 |
+
// reserva. Un RECENT fijo puede comerse el presupuesto entero él solo (una
|
| 354 |
+
// página de 100 líneas son ~1,5k tokens) y devolver varias veces lo pedido.
|
| 355 |
+
const reserve = Math.floor(budgetTok * tuned.recentFrac);
|
| 356 |
+
const recent = []; let used = 0;
|
| 357 |
+
for (let i = history.length - 1, k = 0; i >= 0 && k < O.RECENT; i--, k++) {
|
| 358 |
+
const m = clampMsg(history[i], O.MAX_MSG_CHARS);
|
| 359 |
+
const t = msgTok(m);
|
| 360 |
+
if (used + t > reserve && recent.length >= 1) break;
|
| 361 |
+
recent.unshift(m); used += t;
|
| 362 |
+
}
|
| 363 |
+
let old = history.slice(0, history.length - recent.length);
|
| 364 |
+
if (!old.length || used >= budgetTok) return { done: true, recent, old, used, head: [] };
|
| 365 |
+
|
| 366 |
+
// ── RESERVA DE CABECERA ────────────────────────────────────────────────────
|
| 367 |
+
// Los PRIMEROS mensajes se conservan literales y SIN puntuar, igual que los
|
| 368 |
+
// últimos. No es simetría estética: el arranque lleva la tarea, el encargo,
|
| 369 |
+
// las rutas y las restricciones — cosas que el resto de la sesión da por
|
| 370 |
+
// sabidas y que por eso mismo BM25 no tiene por qué puntuar alto (si nadie
|
| 371 |
+
// las repite, no hay solape con la pregunta de ahora).
|
| 372 |
+
//
|
| 373 |
+
// Y hay respaldo externo: es el resultado central de StreamingLLM — los
|
| 374 |
+
// primeros tokens actúan de SUMIDERO de atención y absorben el 45-55 % de la
|
| 375 |
+
// masa; tirarlos degrada al modelo mucho más de lo que su "relevancia"
|
| 376 |
+
// sugiere. Un recuperador puro no puede ver eso, porque no es una propiedad
|
| 377 |
+
// del texto sino de cómo el modelo lo usa.
|
| 378 |
+
//
|
| 379 |
+
// Por eso va sin condición y acotado: ~10 % del presupuesto. Barato de sobra
|
| 380 |
+
// si sirve, y con un techo duro para que no compita con la búsqueda.
|
| 381 |
+
// Y va con la MISMA puerta que la recencia, porque mide lo mismo. Sonda que
|
| 382 |
+
// pregunta por el encargo original a mitad de sesión, 5 semillas:
|
| 383 |
+
//
|
| 384 |
+
// presupuesto 3.000 (el que usa la app): sin reserva 0/5 · con 5 % 5/5
|
| 385 |
+
// presupuesto 16.000 (holgura): sin reserva 5/5 · con 5 % 5/5
|
| 386 |
+
//
|
| 387 |
+
// Con agobio el encargo se pierde SIEMPRE, y es lo único que el agente no
|
| 388 |
+
// puede reconstruir mirando el código. Con holgura sobrevive solo, así que
|
| 389 |
+
// ahí la reserva es coste puro: medida en el banco de hechos de media sesión
|
| 390 |
+
// costaba −15,1 puntos a 16.000 sin ganar nada. Por eso se apaga.
|
| 391 |
+
const headReserve = tuned.headOn ? Math.floor(budgetTok * O.HEAD_FRAC) : 0;
|
| 392 |
+
const head = []; let headUsed = 0;
|
| 393 |
+
for (let i = 0; i < old.length; i++) {
|
| 394 |
+
const m = clampMsg(old[i], O.MAX_MSG_CHARS);
|
| 395 |
+
const t = msgTok(m);
|
| 396 |
+
if (headUsed + t > headReserve) break;
|
| 397 |
+
head.push(m); headUsed += t;
|
| 398 |
+
}
|
| 399 |
+
old = old.slice(head.length);
|
| 400 |
+
used += headUsed;
|
| 401 |
+
if (!old.length || used >= budgetTok) return { done: true, recent, old, used, head };
|
| 402 |
+
|
| 403 |
+
// La PREGUNTA VIVA: el último turno de usuario que no sea un resultado de
|
| 404 |
+
// herramienta. Esto es lo que se puntúa. No la tarea inicial.
|
| 405 |
+
let query = '';
|
| 406 |
+
for (let i = history.length - 1; i >= 0; i--) {
|
| 407 |
+
const m = history[i];
|
| 408 |
+
if (m.role === 'user' && !O.TOOL_PREFIXES.some(p => m.content.startsWith(p))) { query = m.content; break; }
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
const items = [];
|
| 412 |
+
old.forEach((m, mi) => {
|
| 413 |
+
const lines = m.content.split('\n');
|
| 414 |
+
lines.forEach((line, li) => {
|
| 415 |
+
items.push({ mi, li, line, first: li === 0 });
|
| 416 |
+
});
|
| 417 |
+
});
|
| 418 |
+
|
| 419 |
+
// Corpus del índice = las propias líneas. La IDF sale de aquí: endógena.
|
| 420 |
+
const bm25 = buildBM25(items.map(it => it.line), { k1: O.BM25_K1, b: O.BM25_B });
|
| 421 |
+
const qTerms = [...new Set(terms(query))];
|
| 422 |
+
|
| 423 |
+
const nMsg = Math.max(old.length - 1, 1);
|
| 424 |
+
for (let i = 0; i < items.length; i++) {
|
| 425 |
+
items[i].bm = bm25.scoreDoc(i, qTerms);
|
| 426 |
+
items[i].rec = items[i].mi / nMsg;
|
| 427 |
+
items[i].qHit = items[i].bm > 0;
|
| 428 |
+
}
|
| 429 |
+
return { done: false, recent, old, used, items, query, qTerms, bm25, nMsg, O, head };
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
/** Aplica dedup, selección con presupuesto global, MMR y emisión. */
|
| 433 |
+
function selectAndEmit(ctx, budgetTok, O) {
|
| 434 |
+
const { recent, old, items } = ctx;
|
| 435 |
+
const head = ctx.head || [];
|
| 436 |
+
let used = ctx.used;
|
| 437 |
+
|
| 438 |
+
// dedup exacto / casi exacto en TODO el historial
|
| 439 |
+
// El dedup ve TAMBIÉN la cabecera: lo que ya viaja literal ahí no se vuelve a
|
| 440 |
+
// pagar más abajo. Reservar sitio y luego repetir el mismo contenido sería
|
| 441 |
+
// gastar dos veces el mismo presupuesto.
|
| 442 |
+
let deduped = 0;
|
| 443 |
+
if (O.DEDUP) {
|
| 444 |
+
const bestByKey = new Map();
|
| 445 |
+
for (const m of head) for (const line of (m.content || '').split('\n')) {
|
| 446 |
+
const k = dedupKey(line);
|
| 447 |
+
if (k) bestByKey.set(k, { score: Infinity, dup: false });
|
| 448 |
+
}
|
| 449 |
+
for (const it of items) {
|
| 450 |
+
if (it.first) continue;
|
| 451 |
+
const k = dedupKey(it.line);
|
| 452 |
+
if (!k) continue;
|
| 453 |
+
const prev = bestByKey.get(k);
|
| 454 |
+
if (prev === undefined) { bestByKey.set(k, it); continue; }
|
| 455 |
+
const loser = it.score > prev.score ? prev : it;
|
| 456 |
+
const winner = it.score > prev.score ? it : prev;
|
| 457 |
+
loser.dup = true; deduped++;
|
| 458 |
+
bestByKey.set(k, winner);
|
| 459 |
+
}
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
const perLineCap = Math.max(40, Math.floor((budgetTok - used) * O.PER_LINE_CAP_FRAC));
|
| 463 |
+
const keep = new Set();
|
| 464 |
+
const cost = (it) => Math.min(estimateTokens(it.line), perLineCap) + 1;
|
| 465 |
+
const idOf = (it) => it.mi * 100000 + it.li;
|
| 466 |
+
|
| 467 |
+
// Lo que la pregunta nombra explícitamente nunca se desaloja. Acotado por
|
| 468 |
+
// PIN_MAX para que no se coma el presupuesto.
|
| 469 |
+
let pinned = 0;
|
| 470 |
+
if (O.PIN_QUERY_TERMS) {
|
| 471 |
+
const cands = items.filter(it => it.qHit && !it.dup).sort((a, b) => b.score - a.score).slice(0, O.PIN_MAX);
|
| 472 |
+
for (const it of cands) {
|
| 473 |
+
const c = cost(it);
|
| 474 |
+
if (used + c > budgetTok) break;
|
| 475 |
+
keep.add(idOf(it)); used += c; pinned++; it.pin = true;
|
| 476 |
+
}
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
const pool = items.filter(it => !it.dup && !keep.has(idOf(it)));
|
| 480 |
+
pool.sort((a, b) => b.score - a.score);
|
| 481 |
+
|
| 482 |
+
// λ MEDIDO, no supuesto: si el material es muy redundante (páginas de
|
| 483 |
+
// resultados casi idénticas) hay que penalizar fuerte; si cada línea es
|
| 484 |
+
// distinta, penalizar apenas — ahí un λ alto solo tira información buena.
|
| 485 |
+
// MMR_CAND se escala con lo que cabe: en contexto largo, 600 candidatos
|
| 486 |
+
// fijos dejaban fuera la mayor parte del material antes de mirarlo.
|
| 487 |
+
const lambda = O.MMR_LAMBDA != null ? O.MMR_LAMBDA
|
| 488 |
+
: Math.max(0.15, Math.min(0.8, 2 * measureRedundancy(pool)));
|
| 489 |
+
const cands = O.MMR_CAND != null ? O.MMR_CAND
|
| 490 |
+
: Math.max(400, Math.min(8000, Math.round(budgetTok / 6)));
|
| 491 |
+
if (O.MMR && lambda > 0) {
|
| 492 |
+
const cand = pool.slice(0, cands);
|
| 493 |
+
const tok = new Map(); for (const it of cand) tok.set(idOf(it), simTokens(it.line));
|
| 494 |
+
const maxSim = new Map(cand.map(it => [idOf(it), 0]));
|
| 495 |
+
for (const it of items) if (keep.has(idOf(it))) {
|
| 496 |
+
const ks = simTokens(it.line);
|
| 497 |
+
for (const c of cand) maxSim.set(idOf(c), Math.max(maxSim.get(idOf(c)), jaccard(tok.get(idOf(c)), ks)));
|
| 498 |
+
}
|
| 499 |
+
const remaining = new Set(cand.map(idOf));
|
| 500 |
+
const byId = new Map(cand.map(it => [idOf(it), it]));
|
| 501 |
+
while (remaining.size) {
|
| 502 |
+
let best = null, bestVal = -Infinity;
|
| 503 |
+
for (const id of remaining) {
|
| 504 |
+
const v = byId.get(id).score - lambda * maxSim.get(id);
|
| 505 |
+
if (v > bestVal) { bestVal = v; best = id; }
|
| 506 |
+
}
|
| 507 |
+
remaining.delete(best);
|
| 508 |
+
const it = byId.get(best), c = cost(it);
|
| 509 |
+
if (used + c > budgetTok) continue;
|
| 510 |
+
keep.add(best); used += c;
|
| 511 |
+
const bs = tok.get(best);
|
| 512 |
+
for (const id of remaining) maxSim.set(id, Math.max(maxSim.get(id), jaccard(tok.get(id), bs)));
|
| 513 |
+
if (used >= budgetTok) break;
|
| 514 |
+
}
|
| 515 |
+
}
|
| 516 |
+
for (const it of pool) {
|
| 517 |
+
const id = idOf(it);
|
| 518 |
+
if (keep.has(id)) continue;
|
| 519 |
+
const c = cost(it);
|
| 520 |
+
if (used + c > budgetTok) continue;
|
| 521 |
+
keep.add(id); used += c;
|
| 522 |
+
if (used >= budgetTok) break;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
// Contrato de presupuesto: el coste por línea ignora la sobrecarga por mensaje
|
| 526 |
+
// y los marcadores de omisión, así que se mide el tamaño realmente emitido y se
|
| 527 |
+
// devuelven las líneas peor puntuadas hasta que la salida cabe de verdad.
|
| 528 |
+
const msgTok = (m) => estimateTokens(m.content) + 4;
|
| 529 |
+
const emit = () => {
|
| 530 |
+
let t = recent.reduce((s, m) => s + msgTok(m), 0) + head.reduce((s, m) => s + msgTok(m), 0);
|
| 531 |
+
let open = 0;
|
| 532 |
+
old.forEach((m, mi) => {
|
| 533 |
+
const lines = m.content.split('\n');
|
| 534 |
+
let any = false, run = 0, sub = 0;
|
| 535 |
+
lines.forEach((line, li) => {
|
| 536 |
+
if (keep.has(mi * 100000 + li)) {
|
| 537 |
+
if (run) { sub += 8; run = 0; }
|
| 538 |
+
sub += Math.min(estimateTokens(line), perLineCap); any = true;
|
| 539 |
+
} else run++;
|
| 540 |
+
});
|
| 541 |
+
if (any) { if (run) sub += 8; t += sub + 4; open++; }
|
| 542 |
+
});
|
| 543 |
+
if (open < old.length) t += 12;
|
| 544 |
+
return t;
|
| 545 |
+
};
|
| 546 |
+
let realized = emit();
|
| 547 |
+
if (realized > budgetTok) {
|
| 548 |
+
const kept = items.filter(it => keep.has(idOf(it)) && !it.pin).sort((a, b) => a.score - b.score);
|
| 549 |
+
let p = 0;
|
| 550 |
+
while (realized > budgetTok && p < kept.length) {
|
| 551 |
+
const over = realized - budgetTok; let freed = 0;
|
| 552 |
+
while (p < kept.length && freed < over) {
|
| 553 |
+
const it = kept[p++];
|
| 554 |
+
keep.delete(idOf(it));
|
| 555 |
+
freed += Math.min(estimateTokens(it.line), perLineCap);
|
| 556 |
+
}
|
| 557 |
+
realized = emit();
|
| 558 |
+
}
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
const packed = [];
|
| 562 |
+
let droppedMsgs = 0;
|
| 563 |
+
old.forEach((m, mi) => {
|
| 564 |
+
const lines = m.content.split('\n');
|
| 565 |
+
const out = []; let skipped = 0;
|
| 566 |
+
lines.forEach((line, li) => {
|
| 567 |
+
if (keep.has(mi * 100000 + li)) {
|
| 568 |
+
if (skipped) { out.push(` […${skipped} líneas omitidas…]`); skipped = 0; }
|
| 569 |
+
out.push(truncateToTokens(line, perLineCap));
|
| 570 |
+
} else skipped++;
|
| 571 |
+
});
|
| 572 |
+
if (skipped && out.length) out.push(` […${skipped} líneas omitidas…]`);
|
| 573 |
+
if (!out.length) { droppedMsgs++; return; }
|
| 574 |
+
packed.push({ role: m.role, content: out.join('\n') });
|
| 575 |
+
});
|
| 576 |
+
if (droppedMsgs) packed.push({ role: 'user', content: `[…${droppedMsgs} mensajes antiguos omitidos…]` });
|
| 577 |
+
|
| 578 |
+
return { messages: [...head, ...packed, ...recent], stats: { used, deduped, pinned, droppedMsgs, realized, head: head.length } };
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
/**
|
| 582 |
+
* Empaqueta el historial en `budgetTok` tokens — vía LÉXICA (síncrona).
|
| 583 |
+
* BM25 contra la pregunta viva, IDF endógena, sin heurísticas.
|
| 584 |
+
* Medido: 0,598 de recall de evidencia (v1 con heurísticas: 0,020).
|
| 585 |
+
*/
|
| 586 |
+
function packHistoryACER(history, budgetTok, options) {
|
| 587 |
+
const O = { ...DEFAULTS, ...(options || {}) };
|
| 588 |
+
if (!history.length) return { messages: history, stats: {} };
|
| 589 |
+
const ctx = prepare(history, budgetTok, O);
|
| 590 |
+
if (ctx.done) return { messages: [...(ctx.head || []), ...ctx.recent], stats: { used: ctx.used, dropped: ctx.old.length, head: (ctx.head || []).length } };
|
| 591 |
+
|
| 592 |
+
// Normalización a rango para poder mezclar con la recencia sin que BM25,
|
| 593 |
+
// que no está acotado, se lleve todo por delante.
|
| 594 |
+
const Oa = ctx.O || O; // perillas ya autoajustadas
|
| 595 |
+
const maxBm = ctx.items.reduce((m, it) => Math.max(m, it.bm), 0) || 1;
|
| 596 |
+
for (const it of ctx.items) {
|
| 597 |
+
// Dos estratos separados por 10 — más de lo que la penalización MMR (≤0.8)
|
| 598 |
+
// puede recorrer, así que la diversidad reordena DENTRO de un estrato pero
|
| 599 |
+
// nunca cuela una línea irrelevante por delante de una relevante.
|
| 600 |
+
it.score = it.bm > 0 ? 10 + it.bm / maxBm : (Oa._recTie ? it.rec : 0);
|
| 601 |
+
if (it.first) it.score = Math.max(it.score, 0.5); // cabecera de procedencia
|
| 602 |
+
}
|
| 603 |
+
const r = selectAndEmit(ctx, budgetTok, Oa);
|
| 604 |
+
r.stats.mode = 'lexical';
|
| 605 |
+
return r;
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
/**
|
| 609 |
+
* Empaqueta el historial — vía HÍBRIDA (asíncrona).
|
| 610 |
+
* BM25 + embeddings fusionados por RANGOS. Medido: 0,706.
|
| 611 |
+
*
|
| 612 |
+
* `embed` recibe un array de textos y devuelve un array de vectores. Si no se
|
| 613 |
+
* pasa, esto degrada a `packHistoryACER` sin avisar y sin romper nada.
|
| 614 |
+
* `cache` (opcional, de createEmbedCache) hace que cada texto se codifique una
|
| 615 |
+
* sola vez en toda la sesión.
|
| 616 |
+
*/
|
| 617 |
+
async function packHistoryACERHybrid(history, budgetTok, options) {
|
| 618 |
+
const O = { ...DEFAULTS, ...(options || {}) };
|
| 619 |
+
const embed = O.embed;
|
| 620 |
+
if (!history.length) return { messages: history, stats: {} };
|
| 621 |
+
if (!embed || !O.SEMANTIC) return packHistoryACER(history, budgetTok, options);
|
| 622 |
+
|
| 623 |
+
const ctx = prepare(history, budgetTok, O);
|
| 624 |
+
if (ctx.done) return { messages: [...(ctx.head || []), ...ctx.recent], stats: { used: ctx.used, dropped: ctx.old.length, head: (ctx.head || []).length } };
|
| 625 |
+
|
| 626 |
+
// Lado semántico por BLOQUES: codificar línea a línea es prohibitivo y la
|
| 627 |
+
// señal sobrevive al troceado (medido ~73 % al pasar a frontera arbitraria).
|
| 628 |
+
// El TAMAÑO de bloque sale del presupuesto de codificación, no al revés: se
|
| 629 |
+
// acota el número de llamadas al modelo de embeddings y el bloque crece con
|
| 630 |
+
// el historial. Con contexto largo, un bloque fijo de 5 líneas dispararía
|
| 631 |
+
// miles de codificaciones por turno.
|
| 632 |
+
const SB = Math.max(1, Math.ceil(ctx.items.length / Math.max(1, O.SEM_BUDGET)));
|
| 633 |
+
const blocks = [];
|
| 634 |
+
for (let i = 0; i < ctx.items.length; i += SB) {
|
| 635 |
+
blocks.push({ from: i, text: ctx.items.slice(i, i + SB).map(x => x.line).join('\n').slice(0, 4000) });
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
let semOk = false;
|
| 639 |
+
try {
|
| 640 |
+
const cache = O.cache || createEmbedCache(embed);
|
| 641 |
+
const [qv] = await cache.encode([ctx.query.slice(0, 2000)]);
|
| 642 |
+
const bv = await cache.encode(blocks.map(b => b.text));
|
| 643 |
+
for (let b = 0; b < blocks.length; b++) {
|
| 644 |
+
const s = cosine(qv, bv[b]);
|
| 645 |
+
for (let i = blocks[b].from; i < Math.min(blocks[b].from + SB, ctx.items.length); i++) {
|
| 646 |
+
ctx.items[i].sem = s;
|
| 647 |
+
}
|
| 648 |
+
}
|
| 649 |
+
semOk = true;
|
| 650 |
+
} catch (e) {
|
| 651 |
+
// Si los embeddings fallan (modelo no cargado, sin red, cuota), NO se cae:
|
| 652 |
+
// se sigue por la vía léxica, que ya es 30× mejor que la v1.
|
| 653 |
+
semOk = false;
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
if (!semOk) return packHistoryACER(history, budgetTok, options);
|
| 657 |
+
|
| 658 |
+
// Fusión por rangos: dos listas ordenadas, se fusionan POSICIONES.
|
| 659 |
+
const idOf = (it) => it.mi * 100000 + it.li;
|
| 660 |
+
const byLex = [...ctx.items].sort((a, b) => b.bm - a.bm).map(idOf);
|
| 661 |
+
const bySem = [...ctx.items].sort((a, b) => (b.sem || 0) - (a.sem || 0)).map(idOf);
|
| 662 |
+
const fused = rrfFuse([byLex, bySem], O.RRF_K);
|
| 663 |
+
const maxF = Math.max(...fused.values()) || 1;
|
| 664 |
+
|
| 665 |
+
const Oa = ctx.O || O; // perillas ya autoajustadas
|
| 666 |
+
for (const it of ctx.items) {
|
| 667 |
+
const rel = (it.bm > 0) || ((it.sem || 0) > 0);
|
| 668 |
+
it.score = rel ? 10 + (fused.get(idOf(it)) || 0) / maxF : (Oa._recTie ? it.rec : 0);
|
| 669 |
+
if (it.first) it.score = Math.max(it.score, 0.5);
|
| 670 |
+
}
|
| 671 |
+
const r = selectAndEmit(ctx, budgetTok, Oa);
|
| 672 |
+
r.stats.mode = 'hybrid';
|
| 673 |
+
r.stats.blocks = blocks.length;
|
| 674 |
+
return r;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
/**
|
| 678 |
+
* @deprecated Reglas escritas a mano de la v1. Ya NO participan en la puntuación:
|
| 679 |
+
* medido, hacían que el motor rindiera por debajo de no comprimir nada. Se
|
| 680 |
+
* mantiene exportada solo para no romper importaciones antiguas.
|
| 681 |
+
*/
|
| 682 |
+
function classifyLine(s) {
|
| 683 |
+
const sl = (s || '').toLowerCase();
|
| 684 |
+
if (!s || !s.trim()) return 0.0;
|
| 685 |
+
if (['error', 'failed', 'traceback', 'exception'].some(p => sl.includes(p))) return 0.95;
|
| 686 |
+
return 0.5;
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
/** @deprecated Sustituida por buildBM25 (IDF endógena, saturación y normalización). */
|
| 690 |
+
function makeIdf(docs) {
|
| 691 |
+
const b = buildBM25(docs);
|
| 692 |
+
return b.idf;
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
export {
|
| 696 |
+
packHistoryACER, packHistoryACERHybrid, DEFAULTS,
|
| 697 |
+
estimateTokens, truncateToTokens,
|
| 698 |
+
terms, buildBM25, rrfFuse, cosine, createEmbedCache,
|
| 699 |
+
simTokens, jaccard, dedupKey, clampMsg,
|
| 700 |
+
classifyLine, makeIdf,
|
| 701 |
+
};
|
js/context.js
CHANGED
|
@@ -8,6 +8,96 @@
|
|
| 8 |
// puntuados por BM25-lite contra la consulta actual. Los evictados se
|
| 9 |
// sustituyen por una marca de omisión para que el modelo sepa que falta algo.
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
const RECENT = 6;
|
| 12 |
const MAX_MSG_CHARS = 12000; // ningún mensaje (p.ej. un README enorme) revienta el contexto
|
| 13 |
|
|
@@ -57,6 +147,7 @@ const shrink = m => m.content.startsWith('[resultado') && m.content.length > 600
|
|
| 57 |
|
| 58 |
export function packHistory(history, budgetTokens = 2200) {
|
| 59 |
if (!history.length) return history;
|
|
|
|
| 60 |
// los recientes también se recortan por mensaje: un solo tool-result gigante
|
| 61 |
// (README de un repo grande) reventaba el contexto → «Too many tokens».
|
| 62 |
const recent = history.slice(-RECENT).map(clampMsg);
|
|
@@ -89,3 +180,30 @@ export function packHistory(history, budgetTokens = 2200) {
|
|
| 89 |
if (dropped) packed.push({ role: 'user', content: `[…${dropped} mensajes antiguos omitidos…]` });
|
| 90 |
return [...packed, ...recent];
|
| 91 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
// puntuados por BM25-lite contra la consulta actual. Los evictados se
|
| 9 |
// sustituyen por una marca de omisión para que el modelo sepa que falta algo.
|
| 10 |
|
| 11 |
+
import { packHistoryACER, packHistoryACERHybrid } from './acer-core.js';
|
| 12 |
+
|
| 13 |
+
// ── ACE_R unificado ─────────────────────────────────────────────────────────
|
| 14 |
+
// El empaquetado por MENSAJES de aquí abajo se medió contra sesiones reales de
|
| 15 |
+
// Elffuss Code (leer/buscar/editar un proyecto durante ~50 turnos) y falla por
|
| 16 |
+
// dos motivos: (1) los últimos RECENT mensajes por sí solos ya se salen del
|
| 17 |
+
// presupuesto en el 55% de los turnos (un [resultado code.read] son ~1.5k tokens,
|
| 18 |
+
// no 68 como en el test sintético), así que NINGÚN mensaje antiguo sobrevive y
|
| 19 |
+
// se devuelven ~5.1k tokens cuando se piden 3k; (2) al conservar/tirar mensajes
|
| 20 |
+
// ENTEROS, el dato concreto que el agente vuelve a pedir se pierde aunque BM25
|
| 21 |
+
// puntúe bien ese mensaje. Medido sobre 32 sesiones reales (4 proyectos × 8
|
| 22 |
+
// semillas, 53 turnos): recuerdo de un dato antiguo que el agente vuelve a pedir
|
| 23 |
+
// 9.2% ± 11.3 (esto) vs 67.7% ± 7.9 (acer-core) con el MISMO gasto de tokens;
|
| 24 |
+
// truncar por la cola da 7.9% ± 11.9. acer-core.js selecciona por LÍNEAS con un
|
| 25 |
+
// presupuesto global. Banco: agentic-install/lab/gemma-e2b-cli/agent_test/acer_real.
|
| 26 |
+
// AVISO honesto: la ventaja viene del solape léxico con la PREGUNTA; si la
|
| 27 |
+
// pregunta no nombra lo que busca (solo el fichero), baja a 12.7% vs 6.8%.
|
| 28 |
+
//
|
| 29 |
+
// Para volver al comportamiento anterior (o comparar), en la consola:
|
| 30 |
+
// localStorage.setItem('elffuss.acer', 'v1') → empaquetado por mensajes
|
| 31 |
+
// localStorage.removeItem('elffuss.acer') → ACE_R unificado (por defecto)
|
| 32 |
+
function acerUnificado() {
|
| 33 |
+
try { return localStorage.getItem('elffuss.acer') !== 'v1'; } catch { return true; }
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// ── lado SEMÁNTICO (embeddings) ─────────────────────────────────────────────
|
| 37 |
+
// acer-core.js trae las dos vías: la léxica (BM25, síncrona) y la híbrida (BM25
|
| 38 |
+
// + embeddings fusionados por rangos, asíncrona). Medido en LoCoMo con el mismo
|
| 39 |
+
// presupuesto: BM25 23,59 · embeddings 23,95 · fusión de rangos 28,09 · contexto
|
| 40 |
+
// completo sin comprimir 22,56. La fusión vale +4,50 F1 sobre lo que corría.
|
| 41 |
+
//
|
| 42 |
+
// No es que lo semántico sea mejor —empatan en global— sino que cubre el punto
|
| 43 |
+
// ciego del léxico: cuando la pregunta NO comparte vocabulario con la respuesta,
|
| 44 |
+
// BM25 cae a 18,60 y los embeddings aguantan 24,28; cuando sí lo comparte manda
|
| 45 |
+
// BM25 (29,33 vs 23,58). La fusión se queda con los dos (24,05 / 32,73).
|
| 46 |
+
//
|
| 47 |
+
// POR DEFECTO APAGADO, y no por prudencia: por lo que salió al MEDIRLO.
|
| 48 |
+
//
|
| 49 |
+
// · La primera carga cuesta una descarga de 235 MB (fp16, el camino rápido) o
|
| 50 |
+
// 118 MB (q8, el de respaldo). Para quien usa un proveedor externo
|
| 51 |
+
// (providers/api.js) eso es una descarga que hoy NO EXISTE: pasa de 0 a
|
| 52 |
+
// 235 MB sólo por empaquetar mejor el contexto. Y para quien usa el modelo
|
| 53 |
+
// local son ~+28 % sobre lo que ya descarga.
|
| 54 |
+
// · Sólo compensa con adaptador WebGPU. Sin él se cae a wasm, que mide ~9×
|
| 55 |
+
// más lento y se nota en CADA turno, no sólo en el primero.
|
| 56 |
+
// · A cambio, con WebGPU el turno en régimen es una fracción de segundo y la
|
| 57 |
+
// segunda sesión no descarga nada (transformers.js cachea el modelo).
|
| 58 |
+
//
|
| 59 |
+
// O sea: la ganancia de recuperación es real y grande (+4,50 F1), pero el precio
|
| 60 |
+
// no es despreciable y depende del equipo de quien lo usa. Así que se cablea
|
| 61 |
+
// entero, se deja probado, y se enciende a petición. Con la bandera apagada NI
|
| 62 |
+
// SIQUIERA se importa embed.js: el coste de tenerlo cableado es exactamente cero.
|
| 63 |
+
//
|
| 64 |
+
// localStorage.setItem('elffuss.semantic', 'on') → BM25 + embeddings
|
| 65 |
+
// localStorage.setItem('elffuss.semantic', 'off') → sólo BM25
|
| 66 |
+
// localStorage.removeItem('elffuss.semantic') → sólo BM25 (por defecto)
|
| 67 |
+
function semanticoOn() {
|
| 68 |
+
try { return localStorage.getItem('elffuss.semantic') === 'on'; } catch { return false; }
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// SEM_BUDGET alto A PROPÓSITO, y esto es lo contrario de lo que parece.
|
| 72 |
+
//
|
| 73 |
+
// acer-core deriva el tamaño de bloque como SB = ceil(nLíneas / SEM_BUDGET) y
|
| 74 |
+
// rearma los bloques desde el principio del historial EN CADA TURNO. Así que
|
| 75 |
+
// SEM_BUDGET no acota el coste: acota la RESOLUCIÓN, y de paso decide si la
|
| 76 |
+
// caché por contenido sirve para algo. Con un SEM_BUDGET bajo, cada vez que el
|
| 77 |
+
// historial crece lo justo para que SB suba de entero, TODOS los bloques cambian
|
| 78 |
+
// de texto y la caché falla entera; y como el bloque además engorda con la
|
| 79 |
+
// sesión, el turno se encarece con el tiempo. Con SEM_BUDGET por encima del
|
| 80 |
+
// número de líneas, SB vale 1: un bloque = una línea, el texto de una línea ya
|
| 81 |
+
// codificada NO cambia nunca, y un turno sólo paga las líneas NUEVAS.
|
| 82 |
+
//
|
| 83 |
+
// Medido en el navegador sobre una sesión de agente de 8 turnos (cada turno =
|
| 84 |
+
// una tool-call + 90 líneas de resultado, 752 líneas al final), con la caché
|
| 85 |
+
// viva entre turnos y el mismo modelo (coste relativo por turno, 1,0 = el mejor
|
| 86 |
+
// turno observado del barrido):
|
| 87 |
+
//
|
| 88 |
+
// SEM_BUDGET codificaciones por turno coste por turno
|
| 89 |
+
// 64 ~64, casi todo recodificado 5,2× → 12,2× ← y SUBIENDO
|
| 90 |
+
// 400 49–236, a saltos 0,4× – 8,0× ← picos
|
| 91 |
+
// 4000 92, sólo lo nuevo 2,9× → 0,8× ← plano
|
| 92 |
+
//
|
| 93 |
+
// Con 4000 cada línea se codifica UNA vez en toda la sesión (740 codificaciones
|
| 94 |
+
// para 752 líneas: las repetidas salen gratis por la caché) y el turno se ABARATA
|
| 95 |
+
// según avanza la sesión en vez de encarecerse. Es exactamente el «indexar al
|
| 96 |
+
// escribir» que describe la cabecera de acer-core. El valor está alineado con el
|
| 97 |
+
// tope de la caché de embed.js para que no se desaloje justo lo que el turno
|
| 98 |
+
// siguiente va a volver a pedir.
|
| 99 |
+
const SEM_BUDGET = 4000;
|
| 100 |
+
|
| 101 |
const RECENT = 6;
|
| 102 |
const MAX_MSG_CHARS = 12000; // ningún mensaje (p.ej. un README enorme) revienta el contexto
|
| 103 |
|
|
|
|
| 147 |
|
| 148 |
export function packHistory(history, budgetTokens = 2200) {
|
| 149 |
if (!history.length) return history;
|
| 150 |
+
if (acerUnificado()) return packHistoryACER(history, budgetTokens).messages;
|
| 151 |
// los recientes también se recortan por mensaje: un solo tool-result gigante
|
| 152 |
// (README de un repo grande) reventaba el contexto → «Too many tokens».
|
| 153 |
const recent = history.slice(-RECENT).map(clampMsg);
|
|
|
|
| 180 |
if (dropped) packed.push({ role: 'user', content: `[…${dropped} mensajes antiguos omitidos…]` });
|
| 181 |
return [...packed, ...recent];
|
| 182 |
}
|
| 183 |
+
|
| 184 |
+
/**
|
| 185 |
+
* Igual que packHistory pero por la vía HÍBRIDA (léxica + semántica) cuando la
|
| 186 |
+
* bandera está encendida. Es la que deben usar los proveedores.
|
| 187 |
+
*
|
| 188 |
+
* Degrada al empaquetado de siempre —el mismo, byte a byte— si la bandera está
|
| 189 |
+
* apagada, si el modelo de embeddings no está o no carga, o si codificar falla.
|
| 190 |
+
* Ese camino de vuelta es deliberado: que no haya modelo no puede significar que
|
| 191 |
+
* la app deje de funcionar, sólo que empaqueta como hoy.
|
| 192 |
+
*
|
| 193 |
+
* `packHistory` sigue exportada y síncrona por compatibilidad.
|
| 194 |
+
*/
|
| 195 |
+
export async function packHistoryAsync(history, budgetTokens = 2200) {
|
| 196 |
+
if (!history.length) return history;
|
| 197 |
+
if (!acerUnificado() || !semanticoOn()) return packHistory(history, budgetTokens);
|
| 198 |
+
try {
|
| 199 |
+
// Import DINÁMICO: con la bandera apagada, embed.js —y con él
|
| 200 |
+
// transformers.js y el modelo— no se piden nunca.
|
| 201 |
+
const { embed, embedCache } = await import('./embed.js');
|
| 202 |
+
const r = await packHistoryACERHybrid(history, budgetTokens, {
|
| 203 |
+
embed, cache: embedCache(), SEM_BUDGET,
|
| 204 |
+
});
|
| 205 |
+
return r.messages;
|
| 206 |
+
} catch {
|
| 207 |
+
return packHistory(history, budgetTokens);
|
| 208 |
+
}
|
| 209 |
+
}
|
js/embed.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Lado SEMÁNTICO del motor de contexto (el compañero de acer-core.js).
|
| 2 |
+
//
|
| 3 |
+
// ─────────────────────────────────────────────────────────────────────────────
|
| 4 |
+
// POR QUÉ EXISTE
|
| 5 |
+
//
|
| 6 |
+
// acer-core.js ya trae la vía híbrida completa (`packHistoryACERHybrid`): BM25 y
|
| 7 |
+
// embeddings fusionados por rangos. Lo que le faltaba era una función `embed`,
|
| 8 |
+
// así que degradaba a la vía léxica y en producción sólo corría BM25.
|
| 9 |
+
//
|
| 10 |
+
// Medido en LoCoMo (200 preguntas, F1 del propio repo, modelo real respondiendo,
|
| 11 |
+
// mismo presupuesto de tokens):
|
| 12 |
+
//
|
| 13 |
+
// contexto COMPLETO sin comprimir ..... 22,56
|
| 14 |
+
// BM25 (lo que corría hasta ahora) .... 23,59
|
| 15 |
+
// embeddings solos .................... 23,95
|
| 16 |
+
// fusión de RANGOS (esto) ............. 28,09 ← +4,50 sobre BM25
|
| 17 |
+
//
|
| 18 |
+
// El motivo de que sume no es que lo semántico sea «mejor»: en global EMPATAN
|
| 19 |
+
// (23,59 vs 23,95). Hacen trabajos DISTINTOS. Partiendo las mismas preguntas por
|
| 20 |
+
// solape de vocabulario entre la pregunta y la respuesta:
|
| 21 |
+
//
|
| 22 |
+
// sin solape (n=107) con solape (n=93)
|
| 23 |
+
// BM25 ...................... 18,60 29,33
|
| 24 |
+
// embeddings ................ 24,28 23,58
|
| 25 |
+
// fusión de rangos .......... 24,05 32,73
|
| 26 |
+
//
|
| 27 |
+
// Cuando la pregunta NO nombra literalmente lo que busca, BM25 se hunde (18,60)
|
| 28 |
+
// y lo semántico aguanta (24,28). Cuando sí lo nombra, manda BM25 (29,33). La
|
| 29 |
+
// fusión se queda con los dos. Lo semántico no sustituye al léxico: le cubre el
|
| 30 |
+
// punto ciego. Eso —y no una ganancia global— es lo que se está cableando aquí.
|
| 31 |
+
//
|
| 32 |
+
// ─────────────────────────────────────────────────────────────────────────────
|
| 33 |
+
// MODELO ELEGIDO — Xenova/multilingual-e5-small, dtype q8 (118 MB)
|
| 34 |
+
//
|
| 35 |
+
// Requisitos duros y cómo los cumple:
|
| 36 |
+
// · transformers.js: es la conversión ONNX oficial del mantenedor de la
|
| 37 |
+
// librería → carga con el MISMO `pipeline()` que ya usa providers/onnx.js.
|
| 38 |
+
// · pequeño: 118 MB en q8. Es la variante más liviana del repo (el q4 pesa
|
| 39 |
+
// MÁS —398 MB— porque la matriz de embeddings de un vocabulario de 250k no
|
| 40 |
+
// se cuantiza igual de bien; aquí «menos bits» no significa menos disco).
|
| 41 |
+
// · multilingüe: tokenizador XLM-R, 100 idiomas. Los prompts van en castellano
|
| 42 |
+
// y el código en inglés, y ese cruce es exactamente el punto ciego léxico.
|
| 43 |
+
// · licencia: MIT (intfloat/multilingual-e5-small aguas arriba).
|
| 44 |
+
// · ventana de 512 tokens, que es la que necesitan los bloques que arma
|
| 45 |
+
// acer-core; no es un modelo de frases sueltas.
|
| 46 |
+
//
|
| 47 |
+
// Descartados, y por qué:
|
| 48 |
+
// · Xenova/all-MiniLM-L6-v2 (23 MB, Apache-2.0) — 5× más barato, pero SÓLO
|
| 49 |
+
// inglés. Justo el caso que venimos a cubrir es el castellano.
|
| 50 |
+
// · Xenova/paraphrase-multilingual-MiniLM-L12-v2 (118 MB, Apache-2.0) — mismo
|
| 51 |
+
// peso, pero está afinado para paráfrasis de FRASES con ventana de 128
|
| 52 |
+
// tokens: trocearía los bloques por la mitad.
|
| 53 |
+
// · onnx-community/embeddinggemma-300m-ONNX (175 MB en q4f16 + pesos externos)
|
| 54 |
+
// — mejor calidad, pero 1,5× de descarga y licencia Gemma (uso comercial
|
| 55 |
+
// permitido pero con política de uso y obligaciones de redistribución), no
|
| 56 |
+
// una licencia comercial limpia.
|
| 57 |
+
// · ibm-granite/granite-embedding-107m-multilingual (Apache-2.0) — sólo
|
| 58 |
+
// publica ONNX en fp32: 428 MB, 3,6× la descarga.
|
| 59 |
+
// · minishlab/potion-multilingual-128M (MIT) — estático y rapidísimo, pero su
|
| 60 |
+
// único ONNX pesa 512 MB y transformers.js no lo soporta de serie.
|
| 61 |
+
//
|
| 62 |
+
// DTYPE SEGÚN EL DISPOSITIVO — medido, no supuesto. Mismo modelo, mismo lote de
|
| 63 |
+
// bloques, sólo cambiando dtype y dispositivo (coste relativo, 1,0 = el mejor):
|
| 64 |
+
//
|
| 65 |
+
// fp16 / WebGPU .... 1,0× (235 MB) ← el elegido cuando hay adaptador
|
| 66 |
+
// q4f16/ WebGPU .... 0,6× (205 MB) con bloques medianos; ver punto 2
|
| 67 |
+
// q8 / wasm ...... 8,9× (118 MB) ← la red de seguridad
|
| 68 |
+
// fp32 / wasm ...... 11,0× (470 MB)
|
| 69 |
+
// q8 / WebGPU .... 14,1× (118 MB) ← PEOR que en CPU
|
| 70 |
+
//
|
| 71 |
+
// Dos cosas que contradicen la intuición y por eso van escritas:
|
| 72 |
+
// 1. Los enteros de 8 bits NO se aceleran en WebGPU: ORT-web no tiene kernels
|
| 73 |
+
// para esos operadores y acaba yendo y viniendo de la CPU. El fichero más
|
| 74 |
+
// pequeño resulta ser el MÁS LENTO en la GPU. Elegir dtype por tamaño de
|
| 75 |
+
// descarga, sin medir, habría dado la peor combinación posible.
|
| 76 |
+
// 2. Con textos CORTOS —que es nuestro caso, ver SEM_BUDGET en context.js— el
|
| 77 |
+
// 4-bit se da la vuelta y pierde contra fp16 (2,6× más lento en líneas
|
| 78 |
+
// sueltas): a esas longitudes se paga más por deshacer la cuantización que
|
| 79 |
+
// lo que se ahorra en ancho de banda. Por eso fp16 y no q4f16, aunque en
|
| 80 |
+
// bloques grandes q4f16 gane.
|
| 81 |
+
//
|
| 82 |
+
// De ahí la regla: fp16 si hay adaptador WebGPU de verdad, y q8/wasm como red de
|
| 83 |
+
// seguridad. La red de seguridad FUNCIONA pero es ~9× más lenta, hasta el punto
|
| 84 |
+
// de notarse en cada turno, y ése es uno de los motivos de que el lado semántico
|
| 85 |
+
// vaya apagado por defecto (ver context.js).
|
| 86 |
+
// ─────────────────────────────────────────────────────────────────────────────
|
| 87 |
+
|
| 88 |
+
import { createEmbedCache } from './acer-core.js';
|
| 89 |
+
|
| 90 |
+
export const EMBED_MODEL = {
|
| 91 |
+
id: 'Xenova/multilingual-e5-small',
|
| 92 |
+
dims: 384,
|
| 93 |
+
label: 'multilingual-e5-small',
|
| 94 |
+
// dtype → fichero → descarga
|
| 95 |
+
webgpu: { dtype: 'fp16', sizeMB: 235 },
|
| 96 |
+
wasm: { dtype: 'q8', sizeMB: 118 },
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
// La ventana del modelo son 512 tokens; recortar antes de tokenizar evita pagar
|
| 100 |
+
// el troceado de texto que el propio modelo va a descartar.
|
| 101 |
+
const MAX_CHARS = 2000;
|
| 102 |
+
|
| 103 |
+
// Lotes pequeños: transformers.js rellena cada lote hasta el más largo, así que
|
| 104 |
+
// un lote gigante hace pagar la longitud del peor elemento por todos.
|
| 105 |
+
const BATCH = 16;
|
| 106 |
+
|
| 107 |
+
// e5 se entrenó SIEMPRE con prefijo ('query: ' / 'passage: '), nunca con texto
|
| 108 |
+
// pelado. acer-core llama a la misma `embed` para la pregunta y para los
|
| 109 |
+
// bloques, y no hay forma de distinguirlos sin tocar el núcleo; los autores del
|
| 110 |
+
// modelo documentan usar 'query: ' en AMBOS lados para uso simétrico. Quitarlo
|
| 111 |
+
// del todo sería peor: el modelo nunca vio esa distribución.
|
| 112 |
+
const PREFIX = 'query: ';
|
| 113 |
+
|
| 114 |
+
let pipePromise = null;
|
| 115 |
+
export let backend = null; // {device, dtype, sizeMB} una vez resuelto
|
| 116 |
+
|
| 117 |
+
/**
|
| 118 |
+
* Carga PEREZOSA: nada de esto se toca en el arranque. El módulo entero se
|
| 119 |
+
* importa dinámicamente desde context.js sólo cuando la bandera está encendida,
|
| 120 |
+
* y el modelo no se descarga hasta la primera petición que de verdad lo use.
|
| 121 |
+
*/
|
| 122 |
+
function getPipe(onProgress) {
|
| 123 |
+
if (!pipePromise) {
|
| 124 |
+
pipePromise = (async () => {
|
| 125 |
+
const tf = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@4');
|
| 126 |
+
// navigator.gpu puede EXISTIR sin adaptador real (mismo cuidado que en
|
| 127 |
+
// providers/onnx.js): comprobar el adaptador, no el objeto. Elegir mal
|
| 128 |
+
// aquí significa descargar 235 MB para acabar corriendo en CPU.
|
| 129 |
+
let device = 'wasm';
|
| 130 |
+
if (navigator.gpu) {
|
| 131 |
+
try { device = (await navigator.gpu.requestAdapter()) ? 'webgpu' : 'wasm'; }
|
| 132 |
+
catch { device = 'wasm'; }
|
| 133 |
+
}
|
| 134 |
+
const cfg = { device, ...EMBED_MODEL[device] };
|
| 135 |
+
const pipe = await tf.pipeline('feature-extraction', EMBED_MODEL.id, {
|
| 136 |
+
device,
|
| 137 |
+
dtype: cfg.dtype,
|
| 138 |
+
progress_callback: onProgress,
|
| 139 |
+
});
|
| 140 |
+
backend = cfg; // sólo cuando de verdad está listo: isReady() no miente
|
| 141 |
+
return pipe;
|
| 142 |
+
})().catch(e => {
|
| 143 |
+
pipePromise = null; // que un fallo de red no deje el módulo muerto
|
| 144 |
+
backend = null;
|
| 145 |
+
throw e;
|
| 146 |
+
});
|
| 147 |
+
}
|
| 148 |
+
return pipePromise;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/** Precarga opcional (p.ej. desde Ajustes) sin bloquear ningún turno. */
|
| 152 |
+
export function warmup(onProgress) { return getPipe(onProgress); }
|
| 153 |
+
|
| 154 |
+
/** ¿Está ya cargado? Sirve para decidir si un turno va a pagar la descarga. */
|
| 155 |
+
export function isReady() { return backend !== null; }
|
| 156 |
+
|
| 157 |
+
/**
|
| 158 |
+
* embed(textos) → vectores normalizados de 384 dimensiones.
|
| 159 |
+
* Contrato exacto que espera `packHistoryACERHybrid`: si esto lanza, acer-core
|
| 160 |
+
* se va por la vía léxica sin romperse ni avisar.
|
| 161 |
+
*/
|
| 162 |
+
export async function embed(texts) {
|
| 163 |
+
if (!texts || !texts.length) return [];
|
| 164 |
+
const pipe = await getPipe();
|
| 165 |
+
const out = [];
|
| 166 |
+
for (let i = 0; i < texts.length; i += BATCH) {
|
| 167 |
+
const batch = texts.slice(i, i + BATCH)
|
| 168 |
+
.map(t => PREFIX + String(t == null ? '' : t).slice(0, MAX_CHARS));
|
| 169 |
+
const t = await pipe(batch, { pooling: 'mean', normalize: true });
|
| 170 |
+
for (const v of t.tolist()) out.push(Float32Array.from(v));
|
| 171 |
+
}
|
| 172 |
+
return out;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// Caché de sesión POR CONTENIDO. No es una optimización: es lo que hace viable
|
| 176 |
+
// la idea. acer-core rearma los bloques desde el principio del historial en cada
|
| 177 |
+
// turno, así que sin caché un texto que ya se codificó en el turno 3 se volvería
|
| 178 |
+
// a codificar en el 4, el 5 y el 20 — el coste crecería con el CUADRADO de los
|
| 179 |
+
// turnos. Con caché, cada bloque se codifica una vez por sesión y un turno sólo
|
| 180 |
+
// paga los bloques nuevos.
|
| 181 |
+
// El tope es holgado a propósito: acer-core trocea el historial ENTERO en cada
|
| 182 |
+
// turno, así que si la caché desaloja entradas que el turno siguiente vuelve a
|
| 183 |
+
// pedir, se recodifica gratis. Son vectores de 384 flotantes: ~12 MB llenos.
|
| 184 |
+
let cache = null;
|
| 185 |
+
export function embedCache() {
|
| 186 |
+
if (!cache) cache = createEmbedCache(embed, { max: 8000 });
|
| 187 |
+
return cache;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
/** Para diagnóstico/ajustes: cuántos textos lleva cacheados la sesión. */
|
| 191 |
+
export function embedCacheSize() { return cache ? cache.size() : 0; }
|
js/main.js
CHANGED
|
@@ -559,9 +559,9 @@ function renderSettings() {
|
|
| 559 |
brCard.innerHTML =
|
| 560 |
`<div class="prov-head"><span id="br-dot" class="dot off"></span><b>Bridge local</b><span id="br-status" class="muted" style="margin-left:auto;font-size:.72rem">desconectado</span></div>` +
|
| 561 |
`<p class="muted" style="font-size:.72rem;margin:6px 0">Un pequeño programa que TÚ ejecutas en tu ordenador — le da a la elfa ejecución real (node, npm, python…) sin salir de tu máquina. Nada se instala en el navegador.</p>` +
|
| 562 |
-
`<a class="prov-use" style="text-decoration:none;display:inline-block" href="
|
| 563 |
`<details style="margin-top:6px"><summary class="muted" style="font-size:.7rem;cursor:pointer">otro sistema operativo</summary>` +
|
| 564 |
-
Object.entries(OTHER).filter(([f]) => f !== primary).map(([f, label]) => `<div><a href="
|
| 565 |
`</details>` +
|
| 566 |
`<div class="field" style="margin-top:8px"><label class="muted" style="font-size:.68rem">${t('setBrToken')}</label><input id="br-token" placeholder="${t('brTokenPh')}"></div>` +
|
| 567 |
`<div class="field" style="margin-top:6px"><label class="muted" style="font-size:.68rem">Carpeta de trabajo (opcional — si no, usa una temporal)</label><input id="br-folder" placeholder="/ruta/completa/a/tu/proyecto"></div>` +
|
|
|
|
| 559 |
brCard.innerHTML =
|
| 560 |
`<div class="prov-head"><span id="br-dot" class="dot off"></span><b>Bridge local</b><span id="br-status" class="muted" style="margin-left:auto;font-size:.72rem">desconectado</span></div>` +
|
| 561 |
`<p class="muted" style="font-size:.72rem;margin:6px 0">Un pequeño programa que TÚ ejecutas en tu ordenador — le da a la elfa ejecución real (node, npm, python…) sin salir de tu máquina. Nada se instala en el navegador.</p>` +
|
| 562 |
+
`<a class="prov-use" style="text-decoration:none;display:inline-block" href="bridge-dl/${primary}" download>⬇ Descargar para ${OTHER[primary]}</a>` +
|
| 563 |
`<details style="margin-top:6px"><summary class="muted" style="font-size:.7rem;cursor:pointer">otro sistema operativo</summary>` +
|
| 564 |
+
Object.entries(OTHER).filter(([f]) => f !== primary).map(([f, label]) => `<div><a href="bridge-dl/${f}" download style="color:var(--accent2);font-size:.72rem">${label}</a></div>`).join('') +
|
| 565 |
`</details>` +
|
| 566 |
`<div class="field" style="margin-top:8px"><label class="muted" style="font-size:.68rem">${t('setBrToken')}</label><input id="br-token" placeholder="${t('brTokenPh')}"></div>` +
|
| 567 |
`<div class="field" style="margin-top:6px"><label class="muted" style="font-size:.68rem">Carpeta de trabajo (opcional — si no, usa una temporal)</label><input id="br-folder" placeholder="/ruta/completa/a/tu/proyecto"></div>` +
|
js/providers/api.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
// - kind 'anthropic' → /v1/messages (Claude)
|
| 4 |
// Las llamadas salen DIRECTAS del navegador del usuario al proveedor; la clave
|
| 5 |
// no pasa por ningún servidor nuestro. Streaming SSE en ambos dialectos.
|
| 6 |
-
import {
|
| 7 |
|
| 8 |
let cfg = null;
|
| 9 |
export let name = 'API';
|
|
@@ -26,9 +26,13 @@ export async function chat(history, system, onToken = () => {}) {
|
|
| 26 |
async function openaiChat(history, system, onToken) {
|
| 27 |
const headers = { 'Content-Type': 'application/json' };
|
| 28 |
if (cfg.apiKey) headers.Authorization = 'Bearer ' + cfg.apiKey;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
const body = {
|
| 30 |
model: cfg.model,
|
| 31 |
-
messages: [{ role: 'system', content: system }, ...
|
| 32 |
stream: true,
|
| 33 |
max_tokens: cfg.maxTokens || 1024,
|
| 34 |
};
|
|
@@ -53,6 +57,7 @@ async function openaiChat(history, system, onToken) {
|
|
| 53 |
|
| 54 |
// ---- Anthropic Messages ----
|
| 55 |
async function anthropicChat(history, system, onToken) {
|
|
|
|
| 56 |
const res = await fetch(cfg.baseURL.replace(/\/$/, '') + '/v1/messages', {
|
| 57 |
method: 'POST',
|
| 58 |
headers: {
|
|
@@ -66,7 +71,7 @@ async function anthropicChat(history, system, onToken) {
|
|
| 66 |
max_tokens: cfg.maxTokens || 1024,
|
| 67 |
system,
|
| 68 |
stream: true,
|
| 69 |
-
messages: forAnthropic(
|
| 70 |
}),
|
| 71 |
});
|
| 72 |
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status + ' ' + (await res.text().catch(() => '')).slice(0, 120));
|
|
|
|
| 3 |
// - kind 'anthropic' → /v1/messages (Claude)
|
| 4 |
// Las llamadas salen DIRECTAS del navegador del usuario al proveedor; la clave
|
| 5 |
// no pasa por ningún servidor nuestro. Streaming SSE en ambos dialectos.
|
| 6 |
+
import { packHistoryAsync } from '../context.js';
|
| 7 |
|
| 8 |
let cfg = null;
|
| 9 |
export let name = 'API';
|
|
|
|
| 26 |
async function openaiChat(history, system, onToken) {
|
| 27 |
const headers = { 'Content-Type': 'application/json' };
|
| 28 |
if (cfg.apiKey) headers.Authorization = 'Bearer ' + cfg.apiKey;
|
| 29 |
+
// packHistoryAsync: el empaquetado puede tener que codificar embeddings, así
|
| 30 |
+
// que se resuelve ANTES de armar el cuerpo. Con el lado semántico apagado
|
| 31 |
+
// devuelve exactamente lo mismo que la versión síncrona de siempre.
|
| 32 |
+
const packed = await packHistoryAsync(history, 3000);
|
| 33 |
const body = {
|
| 34 |
model: cfg.model,
|
| 35 |
+
messages: [{ role: 'system', content: system }, ...packed],
|
| 36 |
stream: true,
|
| 37 |
max_tokens: cfg.maxTokens || 1024,
|
| 38 |
};
|
|
|
|
| 57 |
|
| 58 |
// ---- Anthropic Messages ----
|
| 59 |
async function anthropicChat(history, system, onToken) {
|
| 60 |
+
const packed = await packHistoryAsync(history, 3000);
|
| 61 |
const res = await fetch(cfg.baseURL.replace(/\/$/, '') + '/v1/messages', {
|
| 62 |
method: 'POST',
|
| 63 |
headers: {
|
|
|
|
| 71 |
max_tokens: cfg.maxTokens || 1024,
|
| 72 |
system,
|
| 73 |
stream: true,
|
| 74 |
+
messages: forAnthropic(packed),
|
| 75 |
}),
|
| 76 |
});
|
| 77 |
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status + ' ' + (await res.text().catch(() => '')).slice(0, 120));
|
js/providers/onnx.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
// (lab/bitacora/posts/08-jspace-live.html): dtype 'q4' obligatorio — q4f16
|
| 4 |
// genera basura vía WebGPU incluso con shader-f16.
|
| 5 |
import { MODEL } from '../model-config.js';
|
| 6 |
-
import {
|
| 7 |
|
| 8 |
export const name = MODEL.label;
|
| 9 |
let generator = null, TextStreamer = null;
|
|
@@ -45,7 +45,7 @@ export async function chat(history, system, onToken = () => {}) {
|
|
| 45 |
// ACE-lite: eviction por relevancia. Presupuesto amplio (LFM2.5 aguanta
|
| 46 |
// contexto largo); el tope POR MENSAJE (context.js) evita que un README
|
| 47 |
// gigante dispare «Too many tokens requested».
|
| 48 |
-
const messages = [{ role: 'system', content: system }, ...
|
| 49 |
const streamer = new TextStreamer(generator.tokenizer, {
|
| 50 |
skip_prompt: true,
|
| 51 |
skip_special_tokens: true,
|
|
|
|
| 3 |
// (lab/bitacora/posts/08-jspace-live.html): dtype 'q4' obligatorio — q4f16
|
| 4 |
// genera basura vía WebGPU incluso con shader-f16.
|
| 5 |
import { MODEL } from '../model-config.js';
|
| 6 |
+
import { packHistoryAsync } from '../context.js';
|
| 7 |
|
| 8 |
export const name = MODEL.label;
|
| 9 |
let generator = null, TextStreamer = null;
|
|
|
|
| 45 |
// ACE-lite: eviction por relevancia. Presupuesto amplio (LFM2.5 aguanta
|
| 46 |
// contexto largo); el tope POR MENSAJE (context.js) evita que un README
|
| 47 |
// gigante dispare «Too many tokens requested».
|
| 48 |
+
const messages = [{ role: 'system', content: system }, ...(await packHistoryAsync(history, 5000))];
|
| 49 |
const streamer = new TextStreamer(generator.tokenizer, {
|
| 50 |
skip_prompt: true,
|
| 51 |
skip_special_tokens: true,
|