create-panal-agent 0.15.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/_package.json +1 -1
- package/template/src/register.ts +3 -3
- package/template/src/server.ts +171 -8
- package/template/src/traduccion.ts +246 -0
package/package.json
CHANGED
package/template/_package.json
CHANGED
package/template/src/register.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import 'dotenv/config';
|
|
15
|
-
import { createPanalClient, formatAgentMetadata, NATIVE_CURRENCY } from '@panal/sdk';
|
|
15
|
+
import { createPanalClient, formatAgentMetadata, NATIVE_CURRENCY, rutaDeAgente } from '@panal/sdk';
|
|
16
16
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
17
17
|
import { createPublicClient, createWalletClient, formatEther, http, parseEther } from 'viem';
|
|
18
18
|
|
|
@@ -138,7 +138,7 @@ export function loQueFaltaDelPerfil(perfil: typeof PERFIL): string | null {
|
|
|
138
138
|
async function compruebaEndpoint(botUrl: string, yo: string): Promise<string | null> {
|
|
139
139
|
let url: string;
|
|
140
140
|
try {
|
|
141
|
-
url =
|
|
141
|
+
url = rutaDeAgente(botUrl, 'agent.json');
|
|
142
142
|
} catch {
|
|
143
143
|
return `PERFIL.botUrl no es una URL válida: ${botUrl}`;
|
|
144
144
|
}
|
|
@@ -192,7 +192,7 @@ async function compruebaEndpoint(botUrl: string, yo: string): Promise<string | n
|
|
|
192
192
|
async function logoQueSirves(botUrl: string): Promise<string> {
|
|
193
193
|
let url: string;
|
|
194
194
|
try {
|
|
195
|
-
url =
|
|
195
|
+
url = rutaDeAgente(botUrl, 'logo');
|
|
196
196
|
} catch {
|
|
197
197
|
return '';
|
|
198
198
|
}
|
package/template/src/server.ts
CHANGED
|
@@ -32,6 +32,9 @@ import {
|
|
|
32
32
|
buildQuote,
|
|
33
33
|
createPanalClient,
|
|
34
34
|
leerNiveles,
|
|
35
|
+
leerNivelesDeMetadata,
|
|
36
|
+
normalizarIdioma,
|
|
37
|
+
resolverLlm,
|
|
35
38
|
LoopDetected,
|
|
36
39
|
MAINNET_ADDRESSES,
|
|
37
40
|
monad,
|
|
@@ -49,6 +52,7 @@ import {
|
|
|
49
52
|
type CallEnvelope,
|
|
50
53
|
type DeliveredFile,
|
|
51
54
|
type FichaNivel,
|
|
55
|
+
type LlmConfig,
|
|
52
56
|
type Nivel,
|
|
53
57
|
type PermitDomain,
|
|
54
58
|
} from '@panal/sdk';
|
|
@@ -57,6 +61,7 @@ import { privateKeyToAccount } from 'viem/accounts';
|
|
|
57
61
|
import { isAddress, keccak256, parseEther, toBytes, verifyMessage } from 'viem';
|
|
58
62
|
import type { Address } from 'viem';
|
|
59
63
|
import { handleTask, NIVELES, SUBCONTRATA_SKILLS } from './agent.js';
|
|
64
|
+
import { frasesGuardadas, pedirTraduccion } from './traduccion.js';
|
|
60
65
|
import type { AdjuntoRecibido, NivelPropio, TaskContext, TaskFile, TaskResult } from './agent.js';
|
|
61
66
|
import { arrancarVigilante } from './vigilante.js';
|
|
62
67
|
import { historialParaElModelo, recordarTurno, type Turno } from './memoria.js';
|
|
@@ -90,7 +95,7 @@ const MAX_BRIEF_CHARS = 32_000;
|
|
|
90
95
|
// propios clientes van a descartar: si aquí no sobrevive, no se anuncia.
|
|
91
96
|
// ---------------------------------------------------------------------------
|
|
92
97
|
|
|
93
|
-
|
|
98
|
+
let NIVELES_OK = leerNiveles({
|
|
94
99
|
tiers: NIVELES.map((n) => ({ ...n, amountWei: n.wei.toString() })),
|
|
95
100
|
});
|
|
96
101
|
|
|
@@ -102,7 +107,7 @@ if (NIVELES.length > 0 && NIVELES_OK.length !== NIVELES.length) {
|
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
/** El nivel más barato: por debajo de eso, un agente con niveles no trabaja. */
|
|
105
|
-
|
|
110
|
+
let NIVEL_MINIMO = NIVELES_OK[0] ?? null;
|
|
106
111
|
|
|
107
112
|
// Se dicen al arrancar. Antes sólo salían dentro del bloque de subcontratación
|
|
108
113
|
// —que no se imprime si no hay presupuesto—, así que un agente con niveles y
|
|
@@ -133,7 +138,7 @@ for (const n of NIVELES) {
|
|
|
133
138
|
|
|
134
139
|
|
|
135
140
|
/** El tope de encargo del nivel mayor, o el de siempre si no hay niveles. */
|
|
136
|
-
|
|
141
|
+
let TOPE_BRIEF_MAYOR = Math.max(MAX_BRIEF_CHARS, ...NIVELES_OK.map((n) => n.maxBriefChars ?? 0));
|
|
137
142
|
|
|
138
143
|
/**
|
|
139
144
|
* Tope del cuerpo de una petición: sin esto, cualquiera te tumba el proceso.
|
|
@@ -144,7 +149,22 @@ const TOPE_BRIEF_MAYOR = Math.max(MAX_BRIEF_CHARS, ...NIVELES_OK.map((n) => n.ma
|
|
|
144
149
|
* los 32 KB de propina cubren el resto del JSON: la firma, la dirección y el
|
|
145
150
|
* manifiesto de adjuntos que viaja dentro del encargo.
|
|
146
151
|
*/
|
|
147
|
-
|
|
152
|
+
let MAX_BODY = Math.max(256 * 1024, TOPE_BRIEF_MAYOR * 4 + 32 * 1024);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Los tres números de arriba se recalculan cuando cambian los niveles.
|
|
156
|
+
*
|
|
157
|
+
* Cambian porque ahora se pueden editar desde la web sin tocar este código:
|
|
158
|
+
* viven en el `metadataURI` on-chain y el dueño del agente los mueve firmando
|
|
159
|
+
* una transacción. Si estos números se quedaran con lo que había al arrancar,
|
|
160
|
+
* un nivel nuevo de 320 000 caracteres se anunciaría y luego se rechazaría por
|
|
161
|
+
* pasarse del tope, que es la peor de las dos opciones: se cobra y no se hace.
|
|
162
|
+
*/
|
|
163
|
+
function recalcularTopes(): void {
|
|
164
|
+
NIVEL_MINIMO = NIVELES_OK[0] ?? null;
|
|
165
|
+
TOPE_BRIEF_MAYOR = Math.max(MAX_BRIEF_CHARS, ...NIVELES_OK.map((n) => n.maxBriefChars ?? 0));
|
|
166
|
+
MAX_BODY = Math.max(256 * 1024, TOPE_BRIEF_MAYOR * 4 + 32 * 1024);
|
|
167
|
+
}
|
|
148
168
|
|
|
149
169
|
/** Un nivel ya validado, en la forma con la que se anuncia y se devuelve. */
|
|
150
170
|
function comoFicha(n: Nivel): FichaNivel {
|
|
@@ -163,9 +183,22 @@ function nivelDe(pagado: bigint): NivelPropio | null {
|
|
|
163
183
|
if (NIVELES_OK.length === 0) return null;
|
|
164
184
|
const leido = nivelPara(NIVELES_OK, pagado);
|
|
165
185
|
if (!leido) return null;
|
|
166
|
-
// Se
|
|
167
|
-
//
|
|
168
|
-
|
|
186
|
+
// Se prefiere el declarado en `agent.ts`: es el que tiene los tipos que el
|
|
187
|
+
// autor del agente espera en `ctx.nivel`, y el único que puede traer
|
|
188
|
+
// `subcontrata`, que no cabe en la ficha on-chain.
|
|
189
|
+
const propio = NIVELES.find((n) => n.wei === leido.wei);
|
|
190
|
+
if (propio) return propio;
|
|
191
|
+
// Y si viene de la CADENA, se arma uno. Devolver null aquí sería lo peor que
|
|
192
|
+
// podría pasar: el cliente pagó el nivel grande, lo vio anunciado y el
|
|
193
|
+
// agente trabajaría creyendo que no compró ninguno.
|
|
194
|
+
return {
|
|
195
|
+
name: leido.name ?? '',
|
|
196
|
+
...(leido.description === null ? {} : { description: leido.description }),
|
|
197
|
+
wei: leido.wei,
|
|
198
|
+
...(leido.maxBriefChars === null ? {} : { maxBriefChars: leido.maxBriefChars }),
|
|
199
|
+
...(leido.maxAttachChars === null ? {} : { maxAttachChars: leido.maxAttachChars }),
|
|
200
|
+
...(leido.maxAttachCharsTotal === null ? {} : { maxAttachCharsTotal: leido.maxAttachCharsTotal }),
|
|
201
|
+
};
|
|
169
202
|
}
|
|
170
203
|
|
|
171
204
|
const key = process.env.AGENT_PRIVATE_KEY?.trim();
|
|
@@ -178,6 +211,85 @@ const panal = createPanalClient({ account, rpcUrl: process.env.RPC_URL });
|
|
|
178
211
|
|
|
179
212
|
console.log(`Agente ${account.address} escuchando en :${PORT}`);
|
|
180
213
|
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Los niveles de la CADENA mandan sobre los de `agent.ts`.
|
|
216
|
+
//
|
|
217
|
+
// Se pueden editar desde el panel de la web sin tocar una línea de código: van
|
|
218
|
+
// dentro del `metadataURI`, y cambiarlos es una transacción. Este bloque los
|
|
219
|
+
// lee y los deja al mando, porque son LOS QUE VIO EL CLIENTE: es contra la
|
|
220
|
+
// ficha on-chain contra lo que eligió tamaño y contra lo que bloqueó el dinero,
|
|
221
|
+
// así que trabajar con otros sería cobrar por una cosa y hacer otra.
|
|
222
|
+
//
|
|
223
|
+
// Se relee cada rato, y no solo al arrancar, porque el sentido de haberlos
|
|
224
|
+
// sacado del código es justamente no tener que reiniciar nada para cambiarlos.
|
|
225
|
+
// Si la lectura falla, se queda lo que hubiera: un RPC lento no puede dejar sin
|
|
226
|
+
// niveles a un agente que los tiene.
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/** Cada cuánto se vuelve a mirar la ficha. */
|
|
230
|
+
const REFRESCO_NIVELES = 5 * 60 * 1000;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* El modelo con el que este agente traduce SU PROPIA ficha para `?lang=`.
|
|
234
|
+
*
|
|
235
|
+
* Se resuelve una vez y falla a `null` en vez de tumbar el arranque: un agente
|
|
236
|
+
* sin `LLM_API_KEY` es un agente perfectamente válido —hay agentes que no usan
|
|
237
|
+
* modelo ninguno— y quedarse sin poder servir la ficha por no poder traducirla
|
|
238
|
+
* sería dejarlo fuera del mercado por un lujo. Sin modelo, ficha original.
|
|
239
|
+
*/
|
|
240
|
+
const LLM_FICHA: LlmConfig | null = (() => {
|
|
241
|
+
try {
|
|
242
|
+
return resolverLlm(process.env);
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
})();
|
|
247
|
+
|
|
248
|
+
/** Los de `agent.ts`, para poder volver a ellos si la ficha se queda sin niveles. */
|
|
249
|
+
const NIVELES_DEL_CODIGO = NIVELES_OK;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* El nombre y la descripción que este agente tiene EN LA CADENA.
|
|
253
|
+
*
|
|
254
|
+
* La plantilla no los tenía: su `/agent.json` publicaba endpoints y precios, y
|
|
255
|
+
* el texto salía solo del registro. Hacen falta aquí para poder servirlos
|
|
256
|
+
* TRADUCIDOS, que es lo que pide `?lang=`.
|
|
257
|
+
*/
|
|
258
|
+
let FICHA_TEXTO: { name: string; description: string } = { name: '', description: '' };
|
|
259
|
+
|
|
260
|
+
async function refrescarNiveles(): Promise<void> {
|
|
261
|
+
try {
|
|
262
|
+
const ficha = await panal.getAgent(account.address);
|
|
263
|
+
FICHA_TEXTO = {
|
|
264
|
+
name: ficha.metadata.name,
|
|
265
|
+
description: ficha.metadata.description,
|
|
266
|
+
};
|
|
267
|
+
const enCadena = leerNivelesDeMetadata(ficha.metadataURI);
|
|
268
|
+
const antes = NIVELES_OK.map((n) => `${n.wei}:${n.name ?? ''}`).join('|');
|
|
269
|
+
NIVELES_OK = enCadena.length > 0 ? enCadena : NIVELES_DEL_CODIGO;
|
|
270
|
+
const ahora = NIVELES_OK.map((n) => `${n.wei}:${n.name ?? ''}`).join('|');
|
|
271
|
+
if (antes !== ahora) {
|
|
272
|
+
recalcularTopes();
|
|
273
|
+
console.log(
|
|
274
|
+
NIVELES_OK.length > 0
|
|
275
|
+
? `[panal] niveles actualizados desde la cadena (${NIVELES_OK.length}): ` +
|
|
276
|
+
NIVELES_OK.map((n) => `${n.name ?? '?'} ${n.wei}`).join(', ')
|
|
277
|
+
: '[panal] este agente ya no publica niveles',
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
// Sin RPC, con la ficha ilegible o con el agente aún sin registrar: se
|
|
282
|
+
// queda lo que hubiera. Callado, porque esto corre cada cinco minutos y un
|
|
283
|
+
// aviso por cada fallo llenaría el log de un agente que funciona.
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// La primera va ANTES de escuchar: `MAX_BODY` sale del tope del nivel mayor, y
|
|
288
|
+
// arrancar con el número viejo sería anunciar un encargo de 320 000 caracteres
|
|
289
|
+
// y cortarlo al recibirlo.
|
|
290
|
+
await refrescarNiveles();
|
|
291
|
+
setInterval(() => void refrescarNiveles(), REFRESCO_NIVELES).unref();
|
|
292
|
+
|
|
181
293
|
// ---------------------------------------------------------------------------
|
|
182
294
|
// x402: cobrar por llamada, sin escrow.
|
|
183
295
|
//
|
|
@@ -1149,6 +1261,50 @@ const server = createServer((req, res) => {
|
|
|
1149
1261
|
// salía en su tarjeta: un cobro que nadie puede descubrir no existe.
|
|
1150
1262
|
if (url.pathname === '/agent.json' && req.method === 'GET') {
|
|
1151
1263
|
const base = process.env.PUBLIC_URL?.trim().replace(/\/+$/, '') || null;
|
|
1264
|
+
/**
|
|
1265
|
+
* `?lang=fr`: la misma ficha con las frases en francés.
|
|
1266
|
+
*
|
|
1267
|
+
* SIN ESPERAR. Si el idioma ya está traducido se sirve traducido; si no,
|
|
1268
|
+
* se sirve el original y la traducción se encarga por detrás para la
|
|
1269
|
+
* próxima vez. Traducir aquí dentro obliga a no reintentar —nadie espera
|
|
1270
|
+
* a un modelo con la tarjeta en blanco— y sin reintentos un 429 pasajero
|
|
1271
|
+
* dejaba ese idioma sin traducir para siempre.
|
|
1272
|
+
*/
|
|
1273
|
+
const idioma = normalizarIdioma(url.searchParams.get('lang'));
|
|
1274
|
+
const nivelesFicha = NIVELES_OK.map(comoFicha);
|
|
1275
|
+
let descripcion = FICHA_TEXTO.description;
|
|
1276
|
+
/**
|
|
1277
|
+
* En qué idioma va lo que se sirve, y `null` si va en el original.
|
|
1278
|
+
*
|
|
1279
|
+
* Hay que DECIRLO, no dejarlo adivinar. Como la traducción va por detrás,
|
|
1280
|
+
* pedir `?lang=fr` antes de que esté lista devuelve la ficha original con
|
|
1281
|
+
* un 200 impecable: quien la guarde —el indexador lo hace— se queda con
|
|
1282
|
+
* el texto en inglés creyendo que es el francés, y como le llegaron los
|
|
1283
|
+
* diez idiomas da el trabajo por hecho y no vuelve nunca. Pasó en
|
|
1284
|
+
* mainnet: nueve de cada diez «traducciones» del catálogo eran el
|
|
1285
|
+
* original.
|
|
1286
|
+
*/
|
|
1287
|
+
let servidoEn: string | null = null;
|
|
1288
|
+
if (idioma) {
|
|
1289
|
+
const frases = {
|
|
1290
|
+
description: descripcion,
|
|
1291
|
+
tiers: NIVELES_OK.map((n) => ({ name: n.name ?? '', description: n.description ?? '' })),
|
|
1292
|
+
};
|
|
1293
|
+
const traducido = frasesGuardadas(frases, idioma, DATA_DIR);
|
|
1294
|
+
if (!traducido) pedirTraduccion(frases, idioma, LLM_FICHA, DATA_DIR);
|
|
1295
|
+
if (traducido) {
|
|
1296
|
+
servidoEn = idioma;
|
|
1297
|
+
descripcion = traducido.description;
|
|
1298
|
+
traducido.tiers.forEach((t, i) => {
|
|
1299
|
+
const destino = nivelesFicha[i];
|
|
1300
|
+
// Solo se pisa lo que ya había: un nivel sin nombre no gana uno
|
|
1301
|
+
// por pasar por el traductor, y uno con nombre no lo pierde.
|
|
1302
|
+
if (!destino) return;
|
|
1303
|
+
if (destino.name && t.name) destino.name = t.name;
|
|
1304
|
+
if (destino.description && t.description) destino.description = t.description;
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1152
1308
|
const x402 =
|
|
1153
1309
|
X402_PRICE !== null
|
|
1154
1310
|
? {
|
|
@@ -1169,6 +1325,13 @@ const server = createServer((req, res) => {
|
|
|
1169
1325
|
protocol: 'panal',
|
|
1170
1326
|
network: 'monad-mainnet',
|
|
1171
1327
|
chainId: monad.id,
|
|
1328
|
+
// El nombre NO se traduce nunca: «LexPanal» no significa nada en
|
|
1329
|
+
// francés y traducirlo sería inventarle otro nombre a este agente.
|
|
1330
|
+
...(FICHA_TEXTO.name ? { name: FICHA_TEXTO.name } : {}),
|
|
1331
|
+
...(descripcion ? { description: descripcion } : {}),
|
|
1332
|
+
// Solo cuando se ha traducido de verdad. Ausente = esto va en el
|
|
1333
|
+
// idioma en que su dueño lo escribió, aunque lo hayas pedido en otro.
|
|
1334
|
+
...(servidoEn ? { lang: servidoEn } : {}),
|
|
1172
1335
|
endpoints: {
|
|
1173
1336
|
base,
|
|
1174
1337
|
postBrief: {
|
|
@@ -1203,7 +1366,7 @@ const server = createServer((req, res) => {
|
|
|
1203
1366
|
},
|
|
1204
1367
|
// Los niveles, sólo si este agente vende alguno. Ausente significa que
|
|
1205
1368
|
// no los ofrece, y quien lee NO debe inventárselos a partir del precio.
|
|
1206
|
-
...(
|
|
1369
|
+
...(nivelesFicha.length > 0 ? { tiers: nivelesFicha } : {}),
|
|
1207
1370
|
// ALIAS ANTIGUO, en la raíz. Aquí es donde esta plantilla lo publicaba
|
|
1208
1371
|
// antes, y hay clientes ahí fuera que solo miran este sitio. Se sirve
|
|
1209
1372
|
// por compatibilidad y desaparecerá; lo que se lee es `endpoints`.
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panal — tu ficha en el idioma de quien la lee.
|
|
3
|
+
*
|
|
4
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
5
|
+
* QUÉ ARREGLA
|
|
6
|
+
*
|
|
7
|
+
* El marketplace habla diez idiomas; tu ficha, uno. Tu descripción y los
|
|
8
|
+
* nombres de tus niveles son texto que escribiste tú, y salen igual en las
|
|
9
|
+
* diez versiones del escaparate: quien entra en árabe ve toda la interfaz en
|
|
10
|
+
* árabe y tu agente descrito en español, o peor, la descripción en inglés y
|
|
11
|
+
* los niveles en español, que es lo que pasa hoy en mainnet.
|
|
12
|
+
*
|
|
13
|
+
* Aquí `GET /agent.json?lang=fr` devuelve tu MISMA ficha con las frases en
|
|
14
|
+
* francés. Nadie tiene que aprender un formato nuevo: los lectores siguen
|
|
15
|
+
* mirando `description` y `tiers[].name`, solo que traducidos.
|
|
16
|
+
*
|
|
17
|
+
* QUÉ CUESTA, QUE ES LA PREGUNTA DE VERDAD
|
|
18
|
+
*
|
|
19
|
+
* Una llamada a tu modelo por idioma, UNA VEZ. El resultado se guarda en disco
|
|
20
|
+
* con la huella del texto original dentro del nombre, así que:
|
|
21
|
+
*
|
|
22
|
+
* - la segunda petición en francés no llama a nadie;
|
|
23
|
+
* - y si cambias tu descripción, la huella cambia y se vuelve a traducir
|
|
24
|
+
* sola, sin que tengas que acordarte de borrar nada.
|
|
25
|
+
*
|
|
26
|
+
* Diez idiomas son diez llamadas en toda la vida de una descripción. Traducir
|
|
27
|
+
* cuatro frases es la llamada más barata que va a hacer tu agente.
|
|
28
|
+
*
|
|
29
|
+
* NADIE ESPERA A QUE TRADUZCA
|
|
30
|
+
*
|
|
31
|
+
* La ficha se sirve SIEMPRE al momento. Si el idioma ya está guardado va
|
|
32
|
+
* traducida; si no, va original y la traducción se pide POR DETRÁS, para la
|
|
33
|
+
* próxima vez que alguien pregunte por ese idioma.
|
|
34
|
+
*
|
|
35
|
+
* Traducir dentro de la petición obliga a no reintentar, porque nadie va a
|
|
36
|
+
* esperar a un modelo con la tarjeta en blanco. Y sin reintentos un
|
|
37
|
+
* `429 Too Many Requests` —que en una cuenta compartida por cuatro agentes es
|
|
38
|
+
* lo normal, no la excepción— significa «esta ficha no se traduce»; como no se
|
|
39
|
+
* guarda nada, el siguiente que pregunte se come otro 429 y el idioma no llega
|
|
40
|
+
* a traducirse NUNCA. Comprobado contra los agentes de mainnet: la misma
|
|
41
|
+
* petición que falla con cero reintentos entra en cuanto se la deja insistir.
|
|
42
|
+
*
|
|
43
|
+
* Fuera de la petición sí se puede insistir, porque no hay nadie mirando.
|
|
44
|
+
*
|
|
45
|
+
* CUANDO FALLA NO SE NOTA
|
|
46
|
+
*
|
|
47
|
+
* Si el modelo no contesta, se acabó la cuota o no hay `LLM_API_KEY`, se sirve
|
|
48
|
+
* la ficha ORIGINAL. Una traducción es una mejora, no un requisito: quedarse
|
|
49
|
+
* sin ficha por no poder traducirla sería dejar al agente fuera del mercado
|
|
50
|
+
* por un lujo.
|
|
51
|
+
*
|
|
52
|
+
* Y NO SE TRADUCE TU NOMBRE. «LexPanal» no significa nada en francés, y
|
|
53
|
+
* traducirlo sería inventarle otro nombre a tu agente y romper toda referencia
|
|
54
|
+
* escrita a él.
|
|
55
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import { createHash } from 'node:crypto';
|
|
59
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
60
|
+
import { join } from 'node:path';
|
|
61
|
+
import { llmChat, NOMBRE_IDIOMA, type Idioma, type LlmConfig } from '@panal/sdk';
|
|
62
|
+
|
|
63
|
+
/** Lo que se traduce de una ficha. Nada más: el nombre del agente no se toca. */
|
|
64
|
+
export interface Frases {
|
|
65
|
+
description: string;
|
|
66
|
+
/** Nombre y descripción de cada nivel, en el orden en que van en la ficha. */
|
|
67
|
+
tiers: { name: string; description: string }[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Tope de la respuesta que se acepta del modelo, por frase. */
|
|
71
|
+
const MAX_FRASE = 400;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Cuánto se espera al modelo, y cuántas veces se insiste.
|
|
75
|
+
*
|
|
76
|
+
* Holgado porque esto ya NO corre dentro de la petición de la ficha: nadie está
|
|
77
|
+
* mirando. Los reintentos son lo que hace que la traducción llegue; sin ellos
|
|
78
|
+
* un 429 pasajero dejaba el idioma sin traducir para siempre.
|
|
79
|
+
*/
|
|
80
|
+
const ESPERA_MS = 60_000;
|
|
81
|
+
const REINTENTOS = 4;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Los idiomas que se están traduciendo ahora mismo.
|
|
85
|
+
*
|
|
86
|
+
* El indexador pide los diez seguidos, y sin esta lista tres peticiones en
|
|
87
|
+
* francés llegadas antes de que vuelva la primera lanzarían tres traducciones
|
|
88
|
+
* idénticas: tres veces el gasto contra una cuenta que ya va justa de
|
|
89
|
+
* peticiones por minuto, para escribir el mismo archivo.
|
|
90
|
+
*/
|
|
91
|
+
const enCurso = new Set<string>();
|
|
92
|
+
|
|
93
|
+
/** La huella del texto original: si cambia, la traducción guardada ya no vale. */
|
|
94
|
+
function huella(frases: Frases): string {
|
|
95
|
+
return createHash('sha256').update(JSON.stringify(frases)).digest('hex').slice(0, 16);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function rutaCache(dir: string, idioma: Idioma, h: string): string {
|
|
99
|
+
return join(dir, 'idiomas', `${idioma}-${h}.json`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Lo guardado, si vale. Nunca lanza: un archivo roto es como si no estuviera. */
|
|
103
|
+
function leerGuardado(dir: string, idioma: Idioma, h: string): Frases | null {
|
|
104
|
+
try {
|
|
105
|
+
const ruta = rutaCache(dir, idioma, h);
|
|
106
|
+
if (!existsSync(ruta)) return null;
|
|
107
|
+
return validar(JSON.parse(readFileSync(ruta, 'utf8')), null);
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function guardar(dir: string, idioma: Idioma, h: string, frases: Frases): void {
|
|
114
|
+
try {
|
|
115
|
+
mkdirSync(join(dir, 'idiomas'), { recursive: true });
|
|
116
|
+
writeFileSync(rutaCache(dir, idioma, h), JSON.stringify(frases), 'utf8');
|
|
117
|
+
} catch {
|
|
118
|
+
// Sin disco se traduce más veces, que es lo peor que puede pasar aquí.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Lo que devolvió el modelo, comprobado contra la forma que se le pidió.
|
|
124
|
+
*
|
|
125
|
+
* Un modelo puede contestar cualquier cosa: una disculpa, el JSON envuelto en
|
|
126
|
+
* markdown, o la lista con un nivel de más. Lo que no cuadre se descarta
|
|
127
|
+
* ENTERO y se sirve el original, porque media traducción en una tarjeta es
|
|
128
|
+
* peor que ninguna: parece que al agente le falta la mitad de la ficha.
|
|
129
|
+
*
|
|
130
|
+
* `original` sirve para exigir el mismo número de niveles. Con `null` solo se
|
|
131
|
+
* comprueba la forma, que es lo que hace falta al leer del disco.
|
|
132
|
+
*/
|
|
133
|
+
function validar(v: unknown, original: Frases | null): Frases | null {
|
|
134
|
+
if (!v || typeof v !== 'object') return null;
|
|
135
|
+
const { description, tiers } = v as Record<string, unknown>;
|
|
136
|
+
if (typeof description !== 'string' || !description.trim()) return null;
|
|
137
|
+
if (!Array.isArray(tiers)) return null;
|
|
138
|
+
if (original && tiers.length !== original.tiers.length) return null;
|
|
139
|
+
|
|
140
|
+
const salida: Frases['tiers'] = [];
|
|
141
|
+
for (const t of tiers) {
|
|
142
|
+
if (!t || typeof t !== 'object') return null;
|
|
143
|
+
const { name, description: d } = t as Record<string, unknown>;
|
|
144
|
+
if (typeof name !== 'string' || typeof d !== 'string') return null;
|
|
145
|
+
salida.push({ name: name.trim().slice(0, MAX_FRASE), description: d.trim().slice(0, MAX_FRASE) });
|
|
146
|
+
}
|
|
147
|
+
return { description: description.trim().slice(0, MAX_FRASE), tiers: salida };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** El JSON que venga, aunque llegue envuelto en un bloque de markdown. */
|
|
151
|
+
function comoJson(crudo: string): unknown {
|
|
152
|
+
const limpio = crudo.trim().replace(/^```(?:json)?\s*/i, '').replace(/```$/, '');
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(limpio);
|
|
155
|
+
} catch {
|
|
156
|
+
// A veces el modelo escribe una frase antes del JSON. Se busca el objeto.
|
|
157
|
+
const i = limpio.indexOf('{');
|
|
158
|
+
const j = limpio.lastIndexOf('}');
|
|
159
|
+
if (i < 0 || j <= i) return null;
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(limpio.slice(i, j + 1));
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const SISTEMA =
|
|
169
|
+
'You translate short marketplace copy. Reply with JSON only, no explanation, ' +
|
|
170
|
+
'no markdown fence. Keep the exact same JSON shape and the same number of ' +
|
|
171
|
+
'array items you are given. Translate the meaning, not word by word: these ' +
|
|
172
|
+
'are product labels that people choose from, so they must read naturally and ' +
|
|
173
|
+
'stay short. Do not translate brand names, product names or code identifiers.';
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Las frases ya traducidas, si están guardadas. NO llama a nadie.
|
|
177
|
+
*
|
|
178
|
+
* Esta es la que usa la ficha, y por eso es síncrona: contesta en microsegundos
|
|
179
|
+
* y no puede hacer esperar a quien pide `/agent.json`.
|
|
180
|
+
*/
|
|
181
|
+
export function frasesGuardadas(frases: Frases, idioma: Idioma, dir: string): Frases | null {
|
|
182
|
+
return leerGuardado(dir, idioma, huella(frases));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Pide la traducción POR DETRÁS, para la próxima vez.
|
|
187
|
+
*
|
|
188
|
+
* No devuelve nada y no se espera: quien la llama ya ha servido la ficha
|
|
189
|
+
* original. Si sale bien queda guardada y la siguiente petición en ese idioma
|
|
190
|
+
* la encuentra hecha; si sale mal no se entera nadie y se reintentará.
|
|
191
|
+
*/
|
|
192
|
+
export function pedirTraduccion(
|
|
193
|
+
frases: Frases,
|
|
194
|
+
idioma: Idioma,
|
|
195
|
+
llm: LlmConfig | null,
|
|
196
|
+
dir: string,
|
|
197
|
+
): void {
|
|
198
|
+
if (!llm) return;
|
|
199
|
+
if (!frases.description.trim() && frases.tiers.length === 0) return;
|
|
200
|
+
const clave = `${idioma}-${huella(frases)}`;
|
|
201
|
+
if (enCurso.has(clave) || frasesGuardadas(frases, idioma, dir)) return;
|
|
202
|
+
enCurso.add(clave);
|
|
203
|
+
void traducirFrases(frases, idioma, llm, dir).finally(() => enCurso.delete(clave));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Las frases de la ficha en otro idioma, esperando al modelo.
|
|
208
|
+
*
|
|
209
|
+
* Devuelve `null` cuando no se ha podido traducir. La ficha NO la llama
|
|
210
|
+
* directamente —usa el par de arriba—; esta existe para las pruebas y para
|
|
211
|
+
* traducir a mano, donde sí se quiere el resultado.
|
|
212
|
+
*/
|
|
213
|
+
export async function traducirFrases(
|
|
214
|
+
frases: Frases,
|
|
215
|
+
idioma: Idioma,
|
|
216
|
+
llm: LlmConfig | null,
|
|
217
|
+
dir: string,
|
|
218
|
+
): Promise<Frases | null> {
|
|
219
|
+
// Sin nada que traducir no se molesta a nadie.
|
|
220
|
+
if (!frases.description.trim() && frases.tiers.length === 0) return null;
|
|
221
|
+
|
|
222
|
+
const h = huella(frases);
|
|
223
|
+
const guardado = leerGuardado(dir, idioma, h);
|
|
224
|
+
if (guardado) return guardado;
|
|
225
|
+
if (!llm) return null;
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const crudo = await llmChat(
|
|
229
|
+
{ ...llm, timeoutMs: ESPERA_MS, maxRetries: REINTENTOS },
|
|
230
|
+
{
|
|
231
|
+
system: SISTEMA,
|
|
232
|
+
user:
|
|
233
|
+
`Translate the values of this JSON into ${NOMBRE_IDIOMA[idioma]}.\n` +
|
|
234
|
+
'Keep the keys in English and the array in the same order.\n\n' +
|
|
235
|
+
JSON.stringify(frases),
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
const traducido = validar(comoJson(crudo), frases);
|
|
239
|
+
if (!traducido) return null;
|
|
240
|
+
guardar(dir, idioma, h, traducido);
|
|
241
|
+
return traducido;
|
|
242
|
+
} catch {
|
|
243
|
+
// Sin clave, sin cuota, sin red o con el modelo caído: la ficha original.
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|