create-panal-agent 0.12.0 → 0.13.1
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 +6 -4
- package/template/_package.json +1 -0
- package/template/src/adjuntos.ts +359 -0
- package/template/src/agent.ts +38 -41
- package/template/src/salida.ts +391 -0
- package/template/src/server.ts +30 -4
- package/template/src/vigilante.ts +170 -47
- package/template/src/zip.ts +245 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Devolverle al cliente el archivo que pidió.
|
|
3
|
+
*
|
|
4
|
+
* Un agente entrega TEXTO: es lo que se le enseña al cliente y lo que se ancla
|
|
5
|
+
* en la cadena. Pero mucha gente no quiere texto en una caja, quiere un archivo
|
|
6
|
+
* que abrir, reenviar o imprimir. Esto convierte lo uno en lo otro.
|
|
7
|
+
*
|
|
8
|
+
* QUÉ SE ENTREGA SIGUE SIENDO EL TEXTO. El archivo va ADEMÁS, nunca en lugar
|
|
9
|
+
* de él: su hash se cuela en la entrega y acaba en la cadena, así que el
|
|
10
|
+
* cliente puede demostrar que el archivo que se baja es exactamente el que se
|
|
11
|
+
* le entregó. Sustituir el texto por el archivo rompería eso.
|
|
12
|
+
*
|
|
13
|
+
* Y NO SE ADJUNTA SI NO LO PIDIÓ. A varios de estos agentes los llama otro
|
|
14
|
+
* programa que va a leer la respuesta; colgarle un PDF que nadie va a abrir es
|
|
15
|
+
* peso y confusión.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { textoAPdf } from './pdf.js';
|
|
19
|
+
import { escribirZip } from './zip.js';
|
|
20
|
+
|
|
21
|
+
export type Formato = 'pdf' | 'docx' | 'xlsx' | 'md' | 'txt' | 'csv' | 'json';
|
|
22
|
+
|
|
23
|
+
export interface ArchivoDeSalida {
|
|
24
|
+
name: string;
|
|
25
|
+
data: Uint8Array | string;
|
|
26
|
+
mime?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Qué formato pidió, si es que pidió alguno.
|
|
31
|
+
*
|
|
32
|
+
* Se mira el ENCARGO, no la respuesta: es donde la persona lo dice. Y se busca
|
|
33
|
+
* en varios idiomas, porque el mercado no es sólo hispanohablante — un encargo
|
|
34
|
+
* en inglés que pide «as a Word document» tiene que salir en Word.
|
|
35
|
+
*
|
|
36
|
+
* Devuelve `null` cuando no pide nada, que es el caso normal.
|
|
37
|
+
*/
|
|
38
|
+
export function formatoPedido(brief: string): Formato | null {
|
|
39
|
+
const t = brief.toLowerCase();
|
|
40
|
+
const mencion: { formato: Formato; en: number }[] = [];
|
|
41
|
+
|
|
42
|
+
for (const [formato, patron] of PATRONES) {
|
|
43
|
+
for (const m of t.matchAll(patron)) mencion.push({ formato, en: m.index });
|
|
44
|
+
}
|
|
45
|
+
if (mencion.length === 0) return null;
|
|
46
|
+
|
|
47
|
+
// Las que hablan del archivo que ENTRÓ no cuentan. Sin esto, «lee el PDF
|
|
48
|
+
// adjunto y devuélvemelo en Word» entregaba un PDF: el primer formato que
|
|
49
|
+
// aparecía era el de la entrada. Pasó en una prueba de punta a punta, que es
|
|
50
|
+
// donde se ve y no en una frase inventada.
|
|
51
|
+
const deSalida = mencion.filter((x) => !esDeEntrada(t, x.en));
|
|
52
|
+
if (deSalida.length === 0) return null;
|
|
53
|
+
|
|
54
|
+
// Si alguna viene precedida de un verbo de entrega, ésa es la buena.
|
|
55
|
+
const pedida = deSalida.find((x) => ENTREGA.test(t.slice(Math.max(0, x.en - 40), x.en)));
|
|
56
|
+
if (pedida) return pedida.formato;
|
|
57
|
+
|
|
58
|
+
// Y si no, la ÚLTIMA: el formato de salida se suele decir al final.
|
|
59
|
+
return deSalida[deSalida.length - 1]!.formato;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Cómo se nombra cada formato, en los idiomas del mercado. */
|
|
63
|
+
const PATRONES: [Formato, RegExp][] = [
|
|
64
|
+
['pdf', /\bpdfs?\b/g],
|
|
65
|
+
['docx', /\bdocx?\b|\bword\b/g],
|
|
66
|
+
// «hoja de cálculo» y «spreadsheet» van a Excel, no a CSV: quien lo pide así
|
|
67
|
+
// quiere abrirlo y sumar, no un archivo de texto con comas.
|
|
68
|
+
['xlsx', /\bxlsx?\b|\bexcel\b|hoja de c[aá]lculo|\bspreadsheet\b/g],
|
|
69
|
+
['csv', /\bcsvs?\b/g],
|
|
70
|
+
['json', /\bjson\b/g],
|
|
71
|
+
['md', /\bmarkdown\b|\bmd\b/g],
|
|
72
|
+
['txt', /\btxt\b|texto plano|plain text|archivo de texto|text file/g],
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** Que se lo den a uno: lo que distingue pedir un formato de nombrarlo. */
|
|
76
|
+
const ENTREGA =
|
|
77
|
+
/\b(devu[eé]lve|dame|d[aá]melo|entr[eé]ga|env[ií]a|quiero|genera|crea|exporta|conviert|p[aá]sa|as an?|in|into|return|output|format[oe]?|como)\b[^.]{0,30}$/;
|
|
78
|
+
|
|
79
|
+
/** Y lo que delata que se habla del archivo que MANDÓ el cliente. */
|
|
80
|
+
const ENTRADA = /\b(adjunt\w*|attach\w*|subid\w*|uploaded|este|esta|el|la|mi|my|the)\b/;
|
|
81
|
+
|
|
82
|
+
function esDeEntrada(t: string, en: number): boolean {
|
|
83
|
+
const antes = t.slice(Math.max(0, en - 18), en);
|
|
84
|
+
const despues = t.slice(en, en + 30);
|
|
85
|
+
// «el PDF adjunto», «the attached pdf», «mi word»: se habla de lo que entró.
|
|
86
|
+
return /\badjunt|attach|\bsub[ií]|uploaded|que te (mand|pas|envi)/.test(despues) || (ENTRADA.test(antes) && /\badjunt|attach/.test(despues));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** La extensión y el tipo de cada formato. */
|
|
90
|
+
const TIPOS: Record<Formato, { ext: string; mime: string }> = {
|
|
91
|
+
pdf: { ext: 'pdf', mime: 'application/pdf' },
|
|
92
|
+
docx: {
|
|
93
|
+
ext: 'docx',
|
|
94
|
+
mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
95
|
+
},
|
|
96
|
+
xlsx: {
|
|
97
|
+
ext: 'xlsx',
|
|
98
|
+
mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
99
|
+
},
|
|
100
|
+
md: { ext: 'md', mime: 'text/markdown; charset=utf-8' },
|
|
101
|
+
txt: { ext: 'txt', mime: 'text/plain; charset=utf-8' },
|
|
102
|
+
csv: { ext: 'csv', mime: 'text/csv; charset=utf-8' },
|
|
103
|
+
json: { ext: 'json', mime: 'application/json; charset=utf-8' },
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Lo que XML no admite tal cual.
|
|
108
|
+
*
|
|
109
|
+
* Los caracteres de control se quitan además de escapar: uno solo hace que
|
|
110
|
+
* Word se niegue a abrir el archivo ENTERO, sin decir cuál era.
|
|
111
|
+
*/
|
|
112
|
+
function escaparXml(s: string): string {
|
|
113
|
+
return s
|
|
114
|
+
.replace(/&/g, '&')
|
|
115
|
+
.replace(/</g, '<')
|
|
116
|
+
.replace(/>/g, '>')
|
|
117
|
+
.replace(/"/g, '"')
|
|
118
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Un `.docx` de verdad, con lo mínimo que Word exige para abrirlo.
|
|
123
|
+
*
|
|
124
|
+
* Un .docx es un ZIP con tres archivos dentro. No hace falta ninguna librería:
|
|
125
|
+
* cada línea del texto es un `<w:p>` y ya está.
|
|
126
|
+
*/
|
|
127
|
+
export function textoADocx(titulo: string, texto: string): Uint8Array {
|
|
128
|
+
const parrafo = (linea: string, negrita = false): string =>
|
|
129
|
+
`<w:p><w:r>${negrita ? '<w:rPr><w:b/></w:rPr>' : ''}` +
|
|
130
|
+
`<w:t xml:space="preserve">${escaparXml(linea)}</w:t></w:r></w:p>`;
|
|
131
|
+
|
|
132
|
+
const cuerpo = [parrafo(titulo, true), ...texto.split(/\r?\n/).map((l) => parrafo(l))].join('');
|
|
133
|
+
|
|
134
|
+
const documento =
|
|
135
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
136
|
+
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
|
|
137
|
+
`<w:body>${cuerpo}</w:body></w:document>`;
|
|
138
|
+
|
|
139
|
+
const tipos =
|
|
140
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
141
|
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
|
142
|
+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
|
143
|
+
'<Default Extension="xml" ContentType="application/xml"/>' +
|
|
144
|
+
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
|
|
145
|
+
'</Types>';
|
|
146
|
+
|
|
147
|
+
const rels =
|
|
148
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
149
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
150
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' +
|
|
151
|
+
'</Relationships>';
|
|
152
|
+
|
|
153
|
+
const b = (s: string): Uint8Array => new TextEncoder().encode(s);
|
|
154
|
+
return escribirZip([
|
|
155
|
+
{ nombre: '[Content_Types].xml', bytes: b(tipos) },
|
|
156
|
+
{ nombre: '_rels/.rels', bytes: b(rels) },
|
|
157
|
+
{ nombre: 'word/document.xml', bytes: b(documento) },
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Cómo está separada una tabla en texto.
|
|
163
|
+
*
|
|
164
|
+
* Se decide mirando TODAS las líneas y no la primera: una tabla cuya cabecera
|
|
165
|
+
* lleva una coma en un título —«Ventas, por región»— haría creer que el
|
|
166
|
+
* separador es la coma cuando en realidad es el tabulador.
|
|
167
|
+
*/
|
|
168
|
+
function separadorDe(lineas: string[]): '\t' | ',' | null {
|
|
169
|
+
const conTab = lineas.filter((l) => l.includes('\t')).length;
|
|
170
|
+
if (conTab >= lineas.length / 2) return '\t';
|
|
171
|
+
const conComa = lineas.filter((l) => l.includes(',')).length;
|
|
172
|
+
if (conComa >= lineas.length / 2) return ',';
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Un CSV puede traer campos entrecomillados con comas dentro. */
|
|
177
|
+
function partirCsv(linea: string): string[] {
|
|
178
|
+
const campos: string[] = [];
|
|
179
|
+
let actual = '';
|
|
180
|
+
let dentro = false;
|
|
181
|
+
for (let i = 0; i < linea.length; i++) {
|
|
182
|
+
const c = linea[i]!;
|
|
183
|
+
if (c === '"') {
|
|
184
|
+
if (dentro && linea[i + 1] === '"') {
|
|
185
|
+
actual += '"';
|
|
186
|
+
i++;
|
|
187
|
+
} else dentro = !dentro;
|
|
188
|
+
} else if (c === ',' && !dentro) {
|
|
189
|
+
campos.push(actual);
|
|
190
|
+
actual = '';
|
|
191
|
+
} else actual += c;
|
|
192
|
+
}
|
|
193
|
+
campos.push(actual);
|
|
194
|
+
return campos;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** `0` → A, `26` → AA. */
|
|
198
|
+
function letraDe(col: number): string {
|
|
199
|
+
let s = '';
|
|
200
|
+
let n = col + 1;
|
|
201
|
+
while (n > 0) {
|
|
202
|
+
const r = (n - 1) % 26;
|
|
203
|
+
s = String.fromCharCode(65 + r) + s;
|
|
204
|
+
n = Math.floor((n - 1) / 26);
|
|
205
|
+
}
|
|
206
|
+
return s;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Un `.xlsx` con lo mínimo que Excel exige.
|
|
211
|
+
*
|
|
212
|
+
* Los números se escriben COMO NÚMEROS y no como texto. Es la diferencia entre
|
|
213
|
+
* una hoja con la que se puede sumar y una en la que cada celda lleva el
|
|
214
|
+
* triangulito verde de «esto parece un número guardado como texto» — que es
|
|
215
|
+
* justo lo que va a hacer quien pide un Excel: sumar.
|
|
216
|
+
*
|
|
217
|
+
* Las cadenas van en línea (`inlineStr`) en vez de en una tabla compartida:
|
|
218
|
+
* ocupa algo más y ahorra una parte entera del archivo, y aquí el tamaño no es
|
|
219
|
+
* el problema.
|
|
220
|
+
*/
|
|
221
|
+
export function textoAXlsx(titulo: string, texto: string): Uint8Array {
|
|
222
|
+
const lineas = texto.split(/\r?\n/).filter((l, i, a) => l !== '' || i < a.length - 1);
|
|
223
|
+
const sep = separadorDe(lineas);
|
|
224
|
+
const filas = lineas.map((l) => (sep === ',' ? partirCsv(l) : sep === '\t' ? l.split('\t') : [l]));
|
|
225
|
+
|
|
226
|
+
const celdas = (fila: string[], nFila: number): string =>
|
|
227
|
+
fila
|
|
228
|
+
.map((valor, col) => {
|
|
229
|
+
const ref = `${letraDe(col)}${nFila}`;
|
|
230
|
+
if (valor === '') return '';
|
|
231
|
+
// Un número es un número; todo lo demás, texto.
|
|
232
|
+
return /^-?\d+([.,]\d+)?$/.test(valor.trim())
|
|
233
|
+
? `<c r="${ref}"><v>${valor.trim().replace(',', '.')}</v></c>`
|
|
234
|
+
: `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${escaparXml(valor)}</t></is></c>`;
|
|
235
|
+
})
|
|
236
|
+
.join('');
|
|
237
|
+
|
|
238
|
+
const sheet =
|
|
239
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
240
|
+
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>' +
|
|
241
|
+
filas.map((f, i) => `<row r="${i + 1}">${celdas(f, i + 1)}</row>`).join('') +
|
|
242
|
+
'</sheetData></worksheet>';
|
|
243
|
+
|
|
244
|
+
const workbook =
|
|
245
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
246
|
+
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' +
|
|
247
|
+
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
|
|
248
|
+
`<sheets><sheet name="${escaparXml(titulo).slice(0, 31)}" sheetId="1" r:id="rId1"/></sheets></workbook>`;
|
|
249
|
+
|
|
250
|
+
const workbookRels =
|
|
251
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
252
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
253
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' +
|
|
254
|
+
'</Relationships>';
|
|
255
|
+
|
|
256
|
+
const tipos =
|
|
257
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
258
|
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
|
259
|
+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
|
260
|
+
'<Default Extension="xml" ContentType="application/xml"/>' +
|
|
261
|
+
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
|
|
262
|
+
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
|
|
263
|
+
'</Types>';
|
|
264
|
+
|
|
265
|
+
const rels =
|
|
266
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
267
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
268
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
|
|
269
|
+
'</Relationships>';
|
|
270
|
+
|
|
271
|
+
const b = (s: string): Uint8Array => new TextEncoder().encode(s);
|
|
272
|
+
return escribirZip([
|
|
273
|
+
{ nombre: '[Content_Types].xml', bytes: b(tipos) },
|
|
274
|
+
{ nombre: '_rels/.rels', bytes: b(rels) },
|
|
275
|
+
{ nombre: 'xl/workbook.xml', bytes: b(workbook) },
|
|
276
|
+
{ nombre: 'xl/_rels/workbook.xml.rels', bytes: b(workbookRels) },
|
|
277
|
+
{ nombre: 'xl/worksheets/sheet1.xml', bytes: b(sheet) },
|
|
278
|
+
]);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Una tabla, si el texto entregado la lleva dentro.
|
|
283
|
+
*
|
|
284
|
+
* Nace de un resultado real y malo: se le pidió a un agente una hoja de
|
|
285
|
+
* cálculo, entregó su JSON de siempre —correcto— y el Excel salió con UNA
|
|
286
|
+
* columna de frases, porque el texto no traía ni comas ni tabuladores. Válido
|
|
287
|
+
* y sin ningún valor: quien pide un Excel quiere columnas para sumarlas.
|
|
288
|
+
*
|
|
289
|
+
* Así que antes de montar un xlsx o un csv se mira si lo entregado es JSON con
|
|
290
|
+
* una lista de objetos planos. Si lo es, sus claves son la cabecera. Si no, se
|
|
291
|
+
* sigue como antes.
|
|
292
|
+
*/
|
|
293
|
+
export function comoTabla(texto: string): string | null {
|
|
294
|
+
let dato: unknown;
|
|
295
|
+
try {
|
|
296
|
+
dato = JSON.parse(texto);
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// La lista puede ser la raíz, o estar dentro bajo cualquier nombre —los
|
|
302
|
+
// agentes la llaman `hallazgos`, `entries`, `puertos`…
|
|
303
|
+
const lista = Array.isArray(dato)
|
|
304
|
+
? dato
|
|
305
|
+
: dato && typeof dato === 'object'
|
|
306
|
+
? Object.values(dato as Record<string, unknown>).find(
|
|
307
|
+
(v): v is unknown[] => Array.isArray(v) && v.length > 0,
|
|
308
|
+
)
|
|
309
|
+
: undefined;
|
|
310
|
+
if (!lista || lista.length === 0) return null;
|
|
311
|
+
|
|
312
|
+
const filas = lista.filter(
|
|
313
|
+
(x): x is Record<string, unknown> => !!x && typeof x === 'object' && !Array.isArray(x),
|
|
314
|
+
);
|
|
315
|
+
if (filas.length !== lista.length) return null;
|
|
316
|
+
|
|
317
|
+
// La cabecera es la unión de las claves, en el orden en que aparecen: una
|
|
318
|
+
// fila a la que le falte un campo no puede descolocar a las demás.
|
|
319
|
+
const columnas: string[] = [];
|
|
320
|
+
for (const f of filas) for (const k of Object.keys(f)) if (!columnas.includes(k)) columnas.push(k);
|
|
321
|
+
if (columnas.length === 0) return null;
|
|
322
|
+
|
|
323
|
+
const celda = (v: unknown): string => {
|
|
324
|
+
if (v === null || v === undefined) return '';
|
|
325
|
+
// Un objeto anidado no cabe en una celda; se pone su JSON antes que
|
|
326
|
+
// «[object Object]», que no le sirve a nadie.
|
|
327
|
+
if (typeof v === 'object') return JSON.stringify(v);
|
|
328
|
+
return String(v).replace(/[\t\r\n]+/g, ' ');
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
return [
|
|
332
|
+
columnas.map((c) => c.replace(/[_-]+/g, ' ')).join('\t'),
|
|
333
|
+
...filas.map((f) => columnas.map((c) => celda(f[c])).join('\t')),
|
|
334
|
+
].join('\n');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* El archivo listo para adjuntar a la entrega.
|
|
339
|
+
*
|
|
340
|
+
* `paraLeer` es la versión legible del contenido, y existe por un caso real:
|
|
341
|
+
* hay agentes cuyo texto entregado es JSON —bueno para una máquina, ilegible
|
|
342
|
+
* dentro de un PDF—, y ahí se les pasa aparte lo que debe ver una persona. Si
|
|
343
|
+
* no se da, se usa el texto tal cual.
|
|
344
|
+
*/
|
|
345
|
+
export function comoArchivo(
|
|
346
|
+
formato: Formato,
|
|
347
|
+
nombreBase: string,
|
|
348
|
+
titulo: string,
|
|
349
|
+
texto: string,
|
|
350
|
+
paraLeer?: string,
|
|
351
|
+
): ArchivoDeSalida {
|
|
352
|
+
const { ext, mime } = TIPOS[formato];
|
|
353
|
+
const name = `${nombreBase}.${ext}`;
|
|
354
|
+
// Para un Excel o un CSV se busca primero una TABLA dentro de lo entregado:
|
|
355
|
+
// la versión en prosa daría una sola columna de frases, que es un archivo
|
|
356
|
+
// válido y sin ningún valor para quien lo pidió para sumar.
|
|
357
|
+
const tabla = formato === 'xlsx' || formato === 'csv' ? comoTabla(texto) : null;
|
|
358
|
+
const legible = tabla ?? paraLeer ?? texto;
|
|
359
|
+
|
|
360
|
+
switch (formato) {
|
|
361
|
+
case 'pdf':
|
|
362
|
+
return { name, data: textoAPdf(titulo, legible), mime };
|
|
363
|
+
case 'docx':
|
|
364
|
+
return { name, data: textoADocx(titulo, legible), mime };
|
|
365
|
+
case 'xlsx':
|
|
366
|
+
return { name, data: textoAXlsx(titulo, legible), mime };
|
|
367
|
+
// El markdown lleva el título como encabezado, porque es lo que un `.md`
|
|
368
|
+
// hace. Los demás van tal cual: un CSV con un `#` delante deja de ser CSV.
|
|
369
|
+
case 'md':
|
|
370
|
+
return { name, data: `# ${titulo}\n\n${legible}\n`, mime };
|
|
371
|
+
case 'csv':
|
|
372
|
+
// Una tabla en tabuladores se convierte a comas; si no había tabla, el
|
|
373
|
+
// texto va tal cual, que es lo que ya hacía.
|
|
374
|
+
return { name, data: tabla ? aCsv(tabla) : texto, mime };
|
|
375
|
+
default:
|
|
376
|
+
return { name, data: texto, mime };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Tabuladores a comas, entrecomillando sólo lo que lo necesita. */
|
|
381
|
+
function aCsv(tabla: string): string {
|
|
382
|
+
return tabla
|
|
383
|
+
.split('\n')
|
|
384
|
+
.map((fila) =>
|
|
385
|
+
fila
|
|
386
|
+
.split('\t')
|
|
387
|
+
.map((c) => (/[",\n]/.test(c) ? `"${c.replace(/"/g, '""')}"` : c))
|
|
388
|
+
.join(','),
|
|
389
|
+
)
|
|
390
|
+
.join('\n');
|
|
391
|
+
}
|
package/template/src/server.ts
CHANGED
|
@@ -535,9 +535,28 @@ function contexto(
|
|
|
535
535
|
};
|
|
536
536
|
}
|
|
537
537
|
|
|
538
|
-
|
|
538
|
+
/**
|
|
539
|
+
* Cómo acabó un intento de trabajar una tarea.
|
|
540
|
+
*
|
|
541
|
+
* Existe porque `work()` no puede lanzar —también lo llama una ruta HTTP, y
|
|
542
|
+
* una tarea rota no debe tumbar la ronda del vigilante— y sin embargo el
|
|
543
|
+
* vigilante NECESITA distinguir. Antes no podía: un modelo que devolvía 429
|
|
544
|
+
* dos veces seguidas y una entrega perfecta se veían igual desde fuera, así
|
|
545
|
+
* que la tarea se daba por resuelta y se dejaba de mirar. Pasó con la #55.
|
|
546
|
+
*
|
|
547
|
+
* `esperando` no es un fallo y tampoco es un éxito, y por eso no bastaba con
|
|
548
|
+
* relanzar el error: una tarea a la que le faltan adjuntos sale de aquí sin
|
|
549
|
+
* ningún error y sin haberse entregado.
|
|
550
|
+
*/
|
|
551
|
+
export type ResultadoTrabajo = 'entregada' | 'esperando' | 'fallo' | 'en-curso';
|
|
552
|
+
|
|
553
|
+
async function work(
|
|
554
|
+
taskId: bigint,
|
|
555
|
+
brief: string,
|
|
556
|
+
sobre: CallEnvelope | null,
|
|
557
|
+
): Promise<ResultadoTrabajo> {
|
|
539
558
|
const key = taskId.toString();
|
|
540
|
-
if (inFlight.has(key)) return;
|
|
559
|
+
if (inFlight.has(key)) return 'en-curso';
|
|
541
560
|
inFlight.add(key);
|
|
542
561
|
try {
|
|
543
562
|
// Lo PRIMERO, antes de trabajar: si el proceso muere a mitad, esto es lo
|
|
@@ -556,7 +575,10 @@ async function work(taskId: bigint, brief: string, sobre: CallEnvelope | null):
|
|
|
556
575
|
console.log(
|
|
557
576
|
`[panal] #${taskId} en espera de ${faltan.length} adjunto(s): ${faltan.map((f) => f.name).join(', ')}`,
|
|
558
577
|
);
|
|
559
|
-
|
|
578
|
+
// Salida limpia y sin entregar. El vigilante tiene que verlo tal cual:
|
|
579
|
+
// dándola por resuelta, una tarea cuyo adjunto llega tras un reinicio se
|
|
580
|
+
// quedaba esperando para siempre sin que nadie volviera a mirarla.
|
|
581
|
+
return 'esperando';
|
|
560
582
|
}
|
|
561
583
|
if (recibidos.length > 0) console.log(`[panal] #${taskId} con ${recibidos.length} adjunto(s) del cliente`);
|
|
562
584
|
|
|
@@ -590,8 +612,10 @@ async function work(taskId: bigint, brief: string, sobre: CallEnvelope | null):
|
|
|
590
612
|
saveResult(taskId, text);
|
|
591
613
|
const { txHash } = await panal.deliverResult(taskId, text);
|
|
592
614
|
console.log(`[panal] #${taskId} entregada · tx ${txHash}`);
|
|
615
|
+
return 'entregada';
|
|
593
616
|
} catch (err) {
|
|
594
617
|
console.error(`[panal] #${taskId} falló: ${err instanceof Error ? err.message : err}`);
|
|
618
|
+
return 'fallo';
|
|
595
619
|
} finally {
|
|
596
620
|
inFlight.delete(key);
|
|
597
621
|
}
|
|
@@ -1439,7 +1463,9 @@ arrancarVigilante({
|
|
|
1439
1463
|
// que la sostenía murió—, así que esta reanudación no puede seguir gastando
|
|
1440
1464
|
// en nombre de nadie. Si el encargo necesitaba subcontratar, lo hará con el
|
|
1441
1465
|
// presupuesto propio de este agente y no con el de quien llamó.
|
|
1442
|
-
|
|
1466
|
+
// Solo `entregada` cuenta como resuelta. Un fallo del modelo o una espera de
|
|
1467
|
+
// adjuntos devuelven false y la tarea se queda en la lista del vigilante.
|
|
1468
|
+
trabajar: async (taskId, brief) => (await work(taskId, brief, null)) === 'entregada',
|
|
1443
1469
|
reentregar: async (taskId, texto) => {
|
|
1444
1470
|
const { txHash } = await panal.deliverResult(taskId, texto);
|
|
1445
1471
|
console.log(`[vigilante] #${taskId} entregada al segundo intento · tx ${txHash}`);
|