create-panal-agent 0.4.0 → 0.5.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/src/agent.ts +42 -3
- package/template/src/pdf.ts +180 -0
package/package.json
CHANGED
package/template/src/agent.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* anclado en la cadena al entregar. Si luego sirves otra cosa, se nota.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { textoAPdf } from './pdf.js';
|
|
15
|
+
|
|
14
16
|
export interface TaskContext {
|
|
15
17
|
/**
|
|
16
18
|
* El id de la tarea en el escrow, o `null` si esto es una llamada x402: ahí
|
|
@@ -98,7 +100,7 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
98
100
|
const problema = revisar(brief, texto);
|
|
99
101
|
if (!problema) {
|
|
100
102
|
console.log(`[agente] ${etiqueta(ctx)} resuelta: ${texto.length} caracteres`);
|
|
101
|
-
return texto;
|
|
103
|
+
return conPdfSiLoPidio(brief, texto, ctx);
|
|
102
104
|
}
|
|
103
105
|
console.error(`[agente] ${etiqueta(ctx)} intento ${intento}: ${problema}`);
|
|
104
106
|
// A la segunda se entrega igual. Tu revisión puede equivocarse, y un falso
|
|
@@ -106,7 +108,7 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
106
108
|
// entregar algo imperfecto y que él decida, que dejarlo sin nada.
|
|
107
109
|
if (intento === 2) {
|
|
108
110
|
console.error(`[agente] ${etiqueta(ctx)} se entrega pese a: ${problema}`);
|
|
109
|
-
return texto;
|
|
111
|
+
return conPdfSiLoPidio(brief, texto, ctx);
|
|
110
112
|
}
|
|
111
113
|
queja = problema;
|
|
112
114
|
}
|
|
@@ -144,9 +146,42 @@ function revisar(brief: string, resultado: string): string | null {
|
|
|
144
146
|
return 'la respuesta lleva Markdown (**, # o ```) y el cliente lo verá en crudo: devuélvela en texto plano';
|
|
145
147
|
}
|
|
146
148
|
|
|
149
|
+
// Si el cliente pide "y mándamelo en PDF", el modelo tiende a contestar que
|
|
150
|
+
// él no genera archivos — mientras el archivo va adjunto. Prohibírselo en el
|
|
151
|
+
// prompt reduce el problema pero no lo cierra: llegó a salir "no menciono el
|
|
152
|
+
// PDF porque no debo". Se comprueba, y se reintenta.
|
|
153
|
+
//
|
|
154
|
+
// Se busca la palabra JUNTO A una frase autorreferencial, no la palabra
|
|
155
|
+
// suelta: alguien puede encargar de verdad trabajo sobre PDFs, y ahí la
|
|
156
|
+
// palabra es el encargo.
|
|
157
|
+
const meta = /(no (puedo|incluyo|voy a|debo|menciono|se adjunt|se genera)|el sistema (se encarg|lo adjunt)|i (cannot|can't|am not)|the system will)/i;
|
|
158
|
+
for (const linea of resultado.split('\n')) {
|
|
159
|
+
if (/\b(pdf|archivo|fichero|adjunto|attachment|file)\b/i.test(linea) && meta.test(linea)) {
|
|
160
|
+
return 'has comentado que no generas archivos; el agente los adjunta solo. Reescríbelo sin ninguna referencia a archivos, PDFs ni adjuntos: solo el trabajo';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
147
164
|
return null;
|
|
148
165
|
}
|
|
149
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Adjunta el trabajo en PDF si el encargo lo pedía. El texto se entrega igual.
|
|
169
|
+
*
|
|
170
|
+
* Se mira el encargo en vez de mandarlo siempre: a un agente también lo llaman
|
|
171
|
+
* otros programas, y adjuntarles un archivo que no pidieron es peso muerto.
|
|
172
|
+
*
|
|
173
|
+
* Bórralo si tu agente no entrega PDFs, o cámbialo por lo que tú generes: una
|
|
174
|
+
* imagen, un CSV, un ZIP. El motor calcula el hash de lo que devuelvas aquí y
|
|
175
|
+
* lo ancla en la cadena, así que el cliente puede demostrar que el archivo que
|
|
176
|
+
* se baja es exactamente el que le entregaste.
|
|
177
|
+
*/
|
|
178
|
+
function conPdfSiLoPidio(brief: string, texto: string, ctx: TaskContext): TaskResult {
|
|
179
|
+
if (!/\bpdf\b/i.test(brief)) return texto;
|
|
180
|
+
const pdf = textoAPdf(`Panal - entrega ${etiqueta(ctx)}`, texto);
|
|
181
|
+
console.log(`[agente] ${etiqueta(ctx)} PDF de ${pdf.byteLength} bytes adjunto`);
|
|
182
|
+
return { text: texto, files: [{ name: 'entrega.pdf', data: pdf, mime: 'application/pdf' }] };
|
|
183
|
+
}
|
|
184
|
+
|
|
150
185
|
async function pedirAlModelo(brief: string, apiKey: string, queja: string | null): Promise<string> {
|
|
151
186
|
const res = await fetch(`${process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1'}/chat/completions`, {
|
|
152
187
|
method: 'POST',
|
|
@@ -171,7 +206,11 @@ async function pedirAlModelo(brief: string, apiKey: string, queja: string | null
|
|
|
171
206
|
'You are a professional agent on the Panal marketplace. ' +
|
|
172
207
|
'RULE 1: detect the language of the request and reply in that exact same language; never switch. ' +
|
|
173
208
|
'RULE 2: plain text only, never Markdown — no # headings, no ** bold, no backticks. ' +
|
|
174
|
-
'RULE 3: deliver finished professional work, with no preamble or meta-commentary
|
|
209
|
+
'RULE 3: deliver finished professional work, with no preamble or meta-commentary.\n' +
|
|
210
|
+
// El agente adjunta el archivo por su cuenta; el modelo no se entera
|
|
211
|
+
// y, sin esta regla, se disculpa por no poder generarlo.
|
|
212
|
+
'RULE 4: the client may ask for the result as a PDF or a file. The agent attaches it after ' +
|
|
213
|
+
'you answer. Never mention files, PDFs or attachments — not even to say you cannot make them.',
|
|
175
214
|
},
|
|
176
215
|
{ role: 'user', content: brief },
|
|
177
216
|
// La corrección va como un mensaje más: decirle QUÉ falló acierta mucho
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Un PDF de verdad, sin dependencias. Puedes borrar este archivo si tu agente
|
|
3
|
+
* no entrega PDFs.
|
|
4
|
+
*
|
|
5
|
+
* No es una plantilla ni un HTML impreso: se escriben los objetos del PDF a
|
|
6
|
+
* mano, con su tabla xref y sus offsets en bytes. Sale un archivo que abre
|
|
7
|
+
* cualquier lector, y no añade ni un paquete a tus dependencias — meter una
|
|
8
|
+
* librería de 4 MB para pintar texto monoespaciado en un A4 no sale a cuenta.
|
|
9
|
+
*
|
|
10
|
+
* Se usa desde `agent.ts`:
|
|
11
|
+
*
|
|
12
|
+
* const pdf = textoAPdf('Mi informe', texto);
|
|
13
|
+
* return { text: texto, files: [{ name: 'informe.pdf', data: pdf, mime: 'application/pdf' }] };
|
|
14
|
+
*
|
|
15
|
+
* El motor calcula su hash y lo ancla en la cadena; tú no tocas nada de eso.
|
|
16
|
+
*
|
|
17
|
+
* Lo que hace bien y cuesta acertar a mano: parte las líneas largas para que no
|
|
18
|
+
* se salgan del papel, pagina solo, y traduce los símbolos que la codificación
|
|
19
|
+
* del PDF no tiene en vez de destrozarlos en silencio.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A4 en puntos, que es la unidad del PDF. */
|
|
23
|
+
const ANCHO = 595;
|
|
24
|
+
const ALTO = 842;
|
|
25
|
+
const MARGEN = 50;
|
|
26
|
+
const CUERPO = 9.5;
|
|
27
|
+
const INTERLINEA = 12.5;
|
|
28
|
+
/** Cuántas líneas caben en una página con estos márgenes. */
|
|
29
|
+
const LINEAS_POR_PAGINA = Math.floor((ALTO - MARGEN * 2) / INTERLINEA);
|
|
30
|
+
/** Ancho de caracteres a 9.5pt en Courier: 0.6 em, redondeado a la baja. */
|
|
31
|
+
const COLUMNAS = Math.floor((ANCHO - MARGEN * 2) / (CUERPO * 0.6));
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Escapa un texto para meterlo entre paréntesis en un PDF.
|
|
35
|
+
*
|
|
36
|
+
* Los paréntesis delimitan las cadenas, así que uno sin escapar rompe el
|
|
37
|
+
* archivo entero — y un JSON viene lleno de ellos.
|
|
38
|
+
*/
|
|
39
|
+
function escapar(texto: string): string {
|
|
40
|
+
return texto.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parte las líneas largas para que no se salgan del papel.
|
|
45
|
+
*
|
|
46
|
+
* Un PDF no ajusta el texto solo: lo que no cabe, sencillamente no se ve. Con
|
|
47
|
+
* un JSON de una sola línea eso significa entregar una hoja casi en blanco.
|
|
48
|
+
*/
|
|
49
|
+
function ajustar(lineas: string[]): string[] {
|
|
50
|
+
const out: string[] = [];
|
|
51
|
+
for (const linea of lineas) {
|
|
52
|
+
if (linea.length <= COLUMNAS) {
|
|
53
|
+
out.push(linea);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Se conserva la sangría al partir: en un JSON es lo que deja ver la
|
|
57
|
+
// estructura, y sin ella el corte lo vuelve ilegible.
|
|
58
|
+
const sangria = /^\s*/.exec(linea)![0].slice(0, 20);
|
|
59
|
+
let resto = linea;
|
|
60
|
+
let primera = true;
|
|
61
|
+
while (resto.length > 0) {
|
|
62
|
+
const ancho = primera ? COLUMNAS : COLUMNAS - sangria.length;
|
|
63
|
+
out.push((primera ? '' : sangria) + resto.slice(0, ancho));
|
|
64
|
+
resto = resto.slice(ancho);
|
|
65
|
+
primera = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sustitutos ASCII de los símbolos que WinAnsiEncoding no tiene.
|
|
73
|
+
*
|
|
74
|
+
* Sin esto, `Buffer.from(txt, 'latin1')` los recorta al byte bajo y salen
|
|
75
|
+
* caracteres que no significan nada: un "≠" acababa impreso como "`", así que
|
|
76
|
+
* un caso de prueba que decía "b ≠ 0" pasaba a decir "b ` 0". Silencioso, y
|
|
77
|
+
* dentro de un entregable que se cobra.
|
|
78
|
+
*/
|
|
79
|
+
const SUSTITUTOS: Record<string, string> = {
|
|
80
|
+
'≠': '!=', '≤': '<=', '≥': '>=', '≈': '~=', '±': '+/-', '×': 'x', '÷': '/',
|
|
81
|
+
'→': '->', '←': '<-', '⇒': '=>', '∞': 'infinito', '∅': 'vacio',
|
|
82
|
+
'“': '"', '”': '"', '„': '"', '‘': "'", '’': "'", '‹': '<', '›': '>',
|
|
83
|
+
'–': '-', '—': '-', '…': '...', '•': '-', '·': '·', '™': '(TM)', '€': 'EUR',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Latin-1, que es lo que entiende WinAnsiEncoding —la codificación de las
|
|
88
|
+
* fuentes base del PDF—, sustituyendo antes lo que no cabe.
|
|
89
|
+
*
|
|
90
|
+
* Lo que no tiene sustituto se marca con "?" a propósito: un interrogante
|
|
91
|
+
* avisa de que ahí faltaba algo; un carácter aleatorio miente.
|
|
92
|
+
*/
|
|
93
|
+
function aLatin1(texto: string): Buffer {
|
|
94
|
+
const convertido = [...texto]
|
|
95
|
+
.map((c) => {
|
|
96
|
+
if (SUSTITUTOS[c]) return SUSTITUTOS[c];
|
|
97
|
+
return c.codePointAt(0)! <= 0xff ? c : '?';
|
|
98
|
+
})
|
|
99
|
+
.join('');
|
|
100
|
+
return Buffer.from(convertido, 'latin1');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Construye el PDF. Devuelve los bytes, listos para escribir o entregar. */
|
|
104
|
+
export function textoAPdf(titulo: string, contenido: string): Uint8Array {
|
|
105
|
+
const lineas = ajustar([titulo, '', ...contenido.split('\n')]);
|
|
106
|
+
|
|
107
|
+
// Se reparte en páginas antes de escribir nada: hay que saber cuántas son
|
|
108
|
+
// para numerar los objetos, y en un PDF los objetos se referencian por número.
|
|
109
|
+
const paginas: string[][] = [];
|
|
110
|
+
for (let i = 0; i < lineas.length; i += LINEAS_POR_PAGINA) {
|
|
111
|
+
paginas.push(lineas.slice(i, i + LINEAS_POR_PAGINA));
|
|
112
|
+
}
|
|
113
|
+
if (paginas.length === 0) paginas.push(['(sin contenido)']);
|
|
114
|
+
|
|
115
|
+
// Numeración: 1 catálogo, 2 árbol de páginas, 3 fuente, y luego cada página
|
|
116
|
+
// con su flujo de contenido, dos objetos por página.
|
|
117
|
+
const FUENTE = 3;
|
|
118
|
+
const primeraPagina = 4;
|
|
119
|
+
const idPagina = (i: number) => primeraPagina + i * 2;
|
|
120
|
+
const idContenido = (i: number) => primeraPagina + i * 2 + 1;
|
|
121
|
+
|
|
122
|
+
const objetos: Buffer[] = [];
|
|
123
|
+
const add = (n: number, cuerpo: string | Buffer) => {
|
|
124
|
+
objetos[n] = Buffer.concat([
|
|
125
|
+
aLatin1(`${n} 0 obj\n`),
|
|
126
|
+
typeof cuerpo === 'string' ? aLatin1(cuerpo) : cuerpo,
|
|
127
|
+
aLatin1('\nendobj\n'),
|
|
128
|
+
]);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const kids = paginas.map((_, i) => `${idPagina(i)} 0 R`).join(' ');
|
|
132
|
+
add(1, '<< /Type /Catalog /Pages 2 0 R >>');
|
|
133
|
+
add(2, `<< /Type /Pages /Kids [${kids}] /Count ${paginas.length} >>`);
|
|
134
|
+
add(FUENTE, '<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
|
|
135
|
+
|
|
136
|
+
paginas.forEach((lineasPagina, i) => {
|
|
137
|
+
add(
|
|
138
|
+
idPagina(i),
|
|
139
|
+
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${ANCHO} ${ALTO}] ` +
|
|
140
|
+
`/Resources << /Font << /F1 ${FUENTE} 0 R >> >> /Contents ${idContenido(i)} 0 R >>`,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const flujo = aLatin1(
|
|
144
|
+
[
|
|
145
|
+
'BT',
|
|
146
|
+
`/F1 ${CUERPO} Tf`,
|
|
147
|
+
`${INTERLINEA} TL`,
|
|
148
|
+
`${MARGEN} ${ALTO - MARGEN} Td`,
|
|
149
|
+
...lineasPagina.map((l) => `(${escapar(l)}) Tj T*`),
|
|
150
|
+
'ET',
|
|
151
|
+
].join('\n'),
|
|
152
|
+
);
|
|
153
|
+
// /Length va en BYTES, no en caracteres: con acentos no es lo mismo, y un
|
|
154
|
+
// lector estricto rechaza el archivo si no cuadra.
|
|
155
|
+
add(idContenido(i), Buffer.concat([aLatin1(`<< /Length ${flujo.length} >>\nstream\n`), flujo, aLatin1('\nendstream')]));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Ensamblado: hay que ir apuntando el offset en bytes de cada objeto, porque
|
|
159
|
+
// la tabla xref del final los indexa por posición absoluta en el archivo.
|
|
160
|
+
const total = objetos.length - 1;
|
|
161
|
+
const partes: Buffer[] = [aLatin1('%PDF-1.4\n')];
|
|
162
|
+
const offsets: number[] = [];
|
|
163
|
+
let cursor = partes[0]!.length;
|
|
164
|
+
|
|
165
|
+
for (let n = 1; n <= total; n++) {
|
|
166
|
+
offsets[n] = cursor;
|
|
167
|
+
partes.push(objetos[n]!);
|
|
168
|
+
cursor += objetos[n]!.length;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const xref = [
|
|
172
|
+
'xref',
|
|
173
|
+
`0 ${total + 1}`,
|
|
174
|
+
'0000000000 65535 f ',
|
|
175
|
+
...Array.from({ length: total }, (_, i) => `${String(offsets[i + 1]).padStart(10, '0')} 00000 n `),
|
|
176
|
+
].join('\n');
|
|
177
|
+
|
|
178
|
+
partes.push(aLatin1(`${xref}\ntrailer\n<< /Size ${total + 1} /Root 1 0 R >>\nstartxref\n${cursor}\n%%EOF\n`));
|
|
179
|
+
return new Uint8Array(Buffer.concat(partes));
|
|
180
|
+
}
|