@sirdaspdf/core 0.1.0 → 0.3.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.
@@ -0,0 +1,18 @@
1
+ export interface AgentChunk {
2
+ /** Hash del contenido: el mismo texto da el mismo id en cualquier máquina. */
3
+ id: string;
4
+ index: number;
5
+ /** Primera y última página de las que sale el texto (base 1), o null si el Markdown no traía marcas. */
6
+ pages: [number, number] | null;
7
+ /** Ruta de encabezados, del más general al más concreto. */
8
+ section: string[];
9
+ text: string;
10
+ tokens: number;
11
+ }
12
+ /** Dos semillas: 64 bits de identificador bastan para no chocar en un corpus. */
13
+ export declare function contentId(text: string): string;
14
+ /**
15
+ * Parte Markdown (idealmente generado con `pageMarkers: true`) en trozos con
16
+ * procedencia. Las marcas de página se usan y se retiran del texto final.
17
+ */
18
+ export declare function chunkForAgents(markdown: string, maxTokens?: number): AgentChunk[];
@@ -0,0 +1,85 @@
1
+ // Trozos para agentes: lo mismo que chunkMarkdown, más lo que un agente
2
+ // necesita para citar y deduplicar sin volver a leer el PDF: de qué páginas
3
+ // sale cada trozo, en qué sección cae y un identificador estable.
4
+ import { chunkMarkdown } from "./chunk.js";
5
+ import { estimateTokens, PAGE_MARKER } from "./markdown.js";
6
+ const HEADING = /^(#{1,6})\s+(.+)$/;
7
+ /** FNV-1a de 32 bits. Sin node:crypto, para que core siga cargando en el navegador. */
8
+ function fnv1a(text, seed) {
9
+ let h = seed >>> 0;
10
+ for (let i = 0; i < text.length; i++) {
11
+ h ^= text.charCodeAt(i);
12
+ h = Math.imul(h, 0x01000193) >>> 0;
13
+ }
14
+ return h.toString(16).padStart(8, "0");
15
+ }
16
+ /** Dos semillas: 64 bits de identificador bastan para no chocar en un corpus. */
17
+ export function contentId(text) {
18
+ return `fnv1a:${fnv1a(text, 0x811c9dc5)}${fnv1a(text, 0x050c5d1f)}`;
19
+ }
20
+ /** Ruta completa de cada encabezado, en el orden en que aparece. */
21
+ function headingPaths(markdown) {
22
+ const paths = new Map();
23
+ const stack = [];
24
+ for (const line of markdown.split("\n")) {
25
+ const m = line.match(HEADING);
26
+ if (!m)
27
+ continue;
28
+ const level = m[1].length;
29
+ while (stack.length && stack[stack.length - 1].level >= level)
30
+ stack.pop();
31
+ stack.push({ level, text: m[2].trim() });
32
+ if (!paths.has(m[2].trim()))
33
+ paths.set(m[2].trim(), stack.map((s) => s.text));
34
+ }
35
+ return paths;
36
+ }
37
+ const stripMarkers = (text) => text
38
+ .split("\n")
39
+ .filter((l) => !PAGE_MARKER.test(l.trim()))
40
+ .join("\n")
41
+ .replace(/\n{3,}/g, "\n\n")
42
+ .trim();
43
+ /**
44
+ * Parte Markdown (idealmente generado con `pageMarkers: true`) en trozos con
45
+ * procedencia. Las marcas de página se usan y se retiran del texto final.
46
+ */
47
+ export function chunkForAgents(markdown, maxTokens = 2000) {
48
+ const hasMarkers = markdown.split("\n").some((l) => PAGE_MARKER.test(l.trim()));
49
+ const paths = headingPaths(markdown);
50
+ // El texto va en orden, así que la página vigente al empezar un trozo es la
51
+ // última marca vista en los trozos anteriores.
52
+ let current = 1;
53
+ const out = [];
54
+ for (const c of chunkMarkdown(markdown, maxTokens)) {
55
+ const lines = c.text.split("\n");
56
+ let first = null;
57
+ let last = current;
58
+ for (const line of lines) {
59
+ const m = line.trim().match(PAGE_MARKER);
60
+ if (m) {
61
+ current = Number(m[1]);
62
+ continue;
63
+ }
64
+ // Una marca al final del trozo pertenece al siguiente: solo cuenta la
65
+ // página si hay texto real después de ella.
66
+ if (line.trim() && !HEADING.test(line)) {
67
+ if (first === null)
68
+ first = current;
69
+ last = current;
70
+ }
71
+ }
72
+ const text = stripMarkers(c.text);
73
+ if (!text)
74
+ continue;
75
+ out.push({
76
+ id: contentId(text),
77
+ index: out.length + 1,
78
+ pages: hasMarkers ? [first ?? current, Math.max(first ?? current, last)] : null,
79
+ section: c.heading ? (paths.get(c.heading) ?? [c.heading]) : [],
80
+ text,
81
+ tokens: estimateTokens(text),
82
+ });
83
+ }
84
+ return out;
85
+ }
package/dist/anonymize.js CHANGED
@@ -3,20 +3,52 @@ const MONTHS = "enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|set
3
3
  // se filtra. Cubrimos las partículas españolas ("de", "del", "de la/las/los")
4
4
  // y hasta siete palabras, que es lo que llega a tener un nombre compuesto
5
5
  // colombiano con dos apellidos.
6
- const PARTICLE = "(?:de\\s+(?:la|las|los)\\s+|del\\s+|de\\s+)?";
7
- const NAME = `[A-ZÁÉÍÓÚÑ][a-záéíóúñü]+(?:\\s+${PARTICLE}[A-ZÁÉÍÓÚÑ][a-záéíóúñü]+){1,6}`;
8
- const NAME_UPPER = "[A-ZÁÉÍÓÚÑ]{2,}(?:\\s+[A-ZÁÉÍÓÚÑ]{2,}){1,6}";
6
+ // Los espacios dentro de un nombre son de la MISMA línea. Con \s+ el patrón
7
+ // cruzaba el salto y «Paciente: Juan Carlos Perez Gomez» seguido de «Cedula de
8
+ // ciudadania…» capturaba «…Perez Gomez Cedula» como un solo nombre, dejando la
9
+ // palabra suelta sustituida en medio de la frase siguiente. Un párrafo ya llega
10
+ // con sus líneas unidas, así que no se pierde ningún nombre real por esto.
11
+ const SP = "[ \\t]+";
12
+ const PARTICLE = `(?:de${SP}(?:la|las|los)${SP}|del${SP}|de${SP})?`;
13
+ const NAME = `[A-ZÁÉÍÓÚÑ][a-záéíóúñü]+(?:${SP}${PARTICLE}[A-ZÁÉÍÓÚÑ][a-záéíóúñü]+){1,6}`;
14
+ const NAME_UPPER = `[A-ZÁÉÍÓÚÑ]{2,}(?:${SP}[A-ZÁÉÍÓÚÑ]{2,}){1,6}`;
9
15
  /** Vuelve insensible a mayúsculas solo las etiquetas (el nombre sí distingue mayúsculas). */
10
16
  const ci = (src) => src.replace(/[a-záéíóúñ]/g, (ch) => `[${ch}${ch.toUpperCase()}]`);
11
17
  const NAME_LABELS = "nombres?(?:\\s+completo)?|paciente|usuario|afiliado|señora?|sr\\.|sra\\.|don|doña|comprador(?:a)?|vendedor(?:a)?|otorgante|compareciente|demandante|demandado|arrendador(?:a)?|arrendatario|apoderad[oa]|titular|firmado por|name|patient|client|signed by";
18
+ /** Algoritmo de Luhn: todas las tarjetas lo cumplen; la mayoría de números al azar, no. */
19
+ function luhn(value) {
20
+ const d = value.replace(/\D/g, "");
21
+ if (d.length < 13 || d.length > 19)
22
+ return false;
23
+ let sum = 0;
24
+ for (let i = 0; i < d.length; i++) {
25
+ let n = Number(d[d.length - 1 - i]);
26
+ if (i % 2)
27
+ n = n * 2 > 9 ? n * 2 - 9 : n * 2;
28
+ sum += n;
29
+ }
30
+ return sum % 10 === 0;
31
+ }
32
+ /** Números largos de documentos comerciales que no son tarjetas aunque pasen Luhn (1 de cada 10 lo hace por azar). */
33
+ const NOT_A_CARD = /(resoluci[oó]n|cufe|cude|autorizaci[oó]n|radicado|factura|consecutivo|orden|pedido|gu[ií]a|referencia|c[oó]digo|matr[ií]cula|expediente)[^\n]{0,30}$/i;
12
34
  const RULES = [
13
35
  { type: "EMAIL", re: /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g },
14
- { type: "HISTORIA_CLINICA", re: /\b(?:historia\s+cl[ií]nica|h\.?\s?c\.?|n[uú]mero\s+de\s+historia)\s*(?:n[oº°.]*|#|:)?\s*([A-Z0-9-]{4,})/gi, group: 1 },
36
+ // El separador NO puede cruzar una línea en blanco, y el número tiene que
37
+ // llevar al menos un dígito. Sin las dos condiciones, un título «Historia
38
+ // clínica» seguido de «Paciente: Fulano» capturaba la palabra «Paciente»
39
+ // como si fuera el número de historia, la sustituía en todo el documento y
40
+ // con eso rompía el patrón «Paciente: nombre»: el nombre quedaba a la vista.
41
+ {
42
+ type: "HISTORIA_CLINICA",
43
+ re: /\b(?:historia\s+cl[ií]nica|h\.?\s?c\.?|n[uú]mero\s+de\s+historia)[ \t]*(?:n[oº°.]*|#|:)?[ \t]*((?=[A-Z0-9-]*\d)[A-Z0-9-]{4,})/gi,
44
+ group: 1,
45
+ },
15
46
  { type: "NIT", re: /\b(?:NIT\.?\s*:?\s*)?\d{3}\.?\d{3}\.?\d{3}\s?-\s?\d\b/g },
16
- { type: "TARJETA", re: /\b(?:\d{4}[ -]?){3}\d{1,4}\b/g },
47
+ { type: "TARJETA", re: /\b(?:\d{4}[ -]?){3}\d{1,4}\b/g, accept: (v, before) => luhn(v) && !NOT_A_CARD.test(before) },
17
48
  {
18
49
  type: "DOCUMENTO",
19
- re: /\b(?:C\.?\s?C\.?|c[eé]dula(?:\s+de\s+ciudadan[ií]a)?|T\.?\s?I\.?|C\.?\s?E\.?|NUIP|pasaporte|DNI|RUT|CURP|RFC|ID)\s*(?:n[oº°.]*|#|:)?\s*([A-Z]{0,4}\d[\d.\s-]{4,14}\d)/gi,
50
+ // Mismo motivo que arriba: la etiqueta y el número van en la misma línea.
51
+ re: /\b(?:C\.?\s?C\.?|c[eé]dula(?:\s+de\s+ciudadan[ií]a)?|T\.?\s?I\.?|C\.?\s?E\.?|NUIP|pasaporte|DNI|RUT|CURP|RFC|ID)[ \t]*(?:n[oº°.]*|#|:)?[ \t]*([A-Z]{0,4}\d[\d.\s-]{4,14}\d)/gi,
20
52
  group: 1,
21
53
  },
22
54
  { type: "DOCUMENTO", re: /(?<![$€£\d.,]\s?)\b\d{1,3}(?:\.\d{3}){2,3}\b(?!\s*(?:pesos|cop|usd|millones|m²|m2))/gi },
@@ -36,7 +68,14 @@ const RULES = [
36
68
  type: "DIRECCION",
37
69
  re: /\b(?:calle|cll?|carrera|cra|kr|kra|avenida|av|transversal|tv|diagonal|dg|autopista|street|st|avenue|ave)\.?\s*\d+[a-z]?(?:\s*bis)?(?:\s*(?:sur|norte|este))?\s*(?:#|n[oº°.]*|no\.?)\s*\d+[a-z]?\s*-\s*\d+(?:\s*(?:sur|norte|este))?(?:[,\s]+(?:apto|apartamento|oficina|of|int|interior|casa|torre)\.?\s*[\w-]+)*/gi,
38
70
  },
39
- { type: "NOMBRE", re: new RegExp(`(?:${ci(NAME_LABELS)})\\s*[:\\-]?\\s*(${NAME_UPPER}|${NAME})(?![a-záéíóúñ])`, "g"), group: 1 },
71
+ // Se admite un único salto de línea entre la etiqueta y el nombre («Paciente:»
72
+ // al final de una línea y el nombre en la siguiente), pero no una línea en
73
+ // blanco: eso ya es otro párrafo y capturarlo produce falsos positivos.
74
+ {
75
+ type: "NOMBRE",
76
+ re: new RegExp(`(?:${ci(NAME_LABELS)})[ \\t]*[:\\-]?[ \\t]*(?:\\r?\\n[ \\t]*)?(${NAME_UPPER}|${NAME})(?![a-záéíóúñ])`, "g"),
77
+ group: 1,
78
+ },
40
79
  ];
41
80
  export function anonymize(text) {
42
81
  const map = new Map();
@@ -61,6 +100,15 @@ export function anonymize(text) {
61
100
  const target = rule.group ? args[rule.group] : full;
62
101
  if (!target || target.startsWith("["))
63
102
  return full;
103
+ if (rule.accept) {
104
+ // Firma de replace: (coincidencia, ...grupos, offset, cadena[, grupos con nombre]).
105
+ const named = typeof args[args.length - 1] === "object";
106
+ const offset = args[args.length - (named ? 3 : 2)];
107
+ const source = args[args.length - (named ? 2 : 1)];
108
+ const before = source.slice(source.lastIndexOf("\n", offset) + 1, offset);
109
+ if (!rule.accept(target, before))
110
+ return full;
111
+ }
64
112
  if (rule.type === "NOMBRE" && /\[/.test(target))
65
113
  return full;
66
114
  const label = labelFor(rule.type, target);
@@ -0,0 +1,12 @@
1
+ export type DocType = "factura" | "contrato" | "historia_clinica" | "cedula" | "rut" | "extracto_bancario" | "certificado" | "hoja_de_vida" | "desconocido";
2
+ export interface Classification {
3
+ type: DocType;
4
+ /** 0–1. Proporción de señales del ganador frente al total; no es una probabilidad calibrada. */
5
+ confidence: number;
6
+ /** Señales encontradas, para que un humano pueda comprobar por qué. */
7
+ signals: string[];
8
+ /** Segundo candidato, útil cuando la confianza es baja. */
9
+ runnerUp: DocType | null;
10
+ }
11
+ /** Clasifica por el texto (basta con las primeras páginas). */
12
+ export declare function classifyDocument(text: string): Classification;
@@ -0,0 +1,89 @@
1
+ // Tipo de documento por reglas, sin modelo: un agente decide qué hacer con un
2
+ // PDF (qué campos buscar, si anonimizar) antes de leerlo entero, y el contenido
3
+ // nunca sale de la máquina para averiguarlo.
4
+ const RULES = {
5
+ factura: [
6
+ [/factura\s+(electr[oó]nica\s+)?de\s+venta/i, 4],
7
+ [/\bcufe\b/i, 4],
8
+ [/\binvoice\b/i, 3],
9
+ [/\biva\b/i, 1],
10
+ [/subtotal/i, 2],
11
+ [/total\s+a\s+pagar/i, 2],
12
+ [/resoluci[oó]n\s+dian/i, 3],
13
+ ],
14
+ contrato: [
15
+ [/\bcontrato\s+de\b/i, 4],
16
+ [/cl[aá]usula\s+(primera|segunda|tercera|\d+)/i, 3],
17
+ [/\bel\s+contratante\b|\bel\s+contratista\b/i, 3],
18
+ [/\bpartes\b.*\bacuerdan\b/is, 2],
19
+ [/\bagreement\b|\bwhereas\b/i, 3],
20
+ [/en\s+constancia\s+se\s+firma/i, 2],
21
+ ],
22
+ historia_clinica: [
23
+ [/historia\s+cl[ií]nica/i, 4],
24
+ [/\bepicrisis\b/i, 4],
25
+ [/motivo\s+de\s+consulta/i, 3],
26
+ [/\bdiagn[oó]stico\b/i, 2],
27
+ [/\bcie-?10\b/i, 3],
28
+ [/signos\s+vitales|tensi[oó]n\s+arterial/i, 2],
29
+ ],
30
+ cedula: [
31
+ [/c[eé]dula\s+de\s+ciudadan[ií]a/i, 4],
32
+ [/registradur[ií]a\s+nacional/i, 4],
33
+ [/fecha\s+y\s+lugar\s+de\s+nacimiento/i, 2],
34
+ [/\bgrupo\s+sangu[ií]neo\b|\bG\.?S\.?\s*RH\b/i, 2],
35
+ ],
36
+ rut: [
37
+ [/registro\s+[uú]nico\s+tributario/i, 5],
38
+ [/formulario\s+del\s+registro/i, 2],
39
+ [/responsabilidades,?\s+calidades/i, 3],
40
+ [/direcci[oó]n\s+seccional/i, 2],
41
+ ],
42
+ extracto_bancario: [
43
+ [/extracto/i, 3],
44
+ [/saldo\s+(anterior|final|disponible)/i, 3],
45
+ [/movimientos/i, 2],
46
+ [/\bbank\s+statement\b/i, 4],
47
+ [/n[uú]mero\s+de\s+cuenta/i, 1],
48
+ ],
49
+ certificado: [
50
+ [/\bcertifica(do)?\b/i, 2],
51
+ [/hace\s+constar/i, 3],
52
+ [/certificado\s+de\s+(existencia|tradici[oó]n|ingresos|retenci[oó]n)/i, 4],
53
+ ],
54
+ hoja_de_vida: [
55
+ [/hoja\s+de\s+vida|curr[ií]culum/i, 4],
56
+ [/experiencia\s+(laboral|profesional)/i, 3],
57
+ [/formaci[oó]n\s+acad[eé]mica|educaci[oó]n/i, 2],
58
+ [/referencias\s+(personales|laborales)/i, 2],
59
+ ],
60
+ };
61
+ /** Clasifica por el texto (basta con las primeras páginas). */
62
+ export function classifyDocument(text) {
63
+ const sample = text.slice(0, 20000);
64
+ const scores = [];
65
+ for (const [type, rules] of Object.entries(RULES)) {
66
+ let score = 0;
67
+ const signals = [];
68
+ for (const [re, weight] of rules) {
69
+ const m = sample.match(re);
70
+ if (m) {
71
+ score += weight;
72
+ signals.push(m[0].replace(/\s+/g, " ").trim().slice(0, 40));
73
+ }
74
+ }
75
+ scores.push({ type, score, signals });
76
+ }
77
+ scores.sort((a, b) => b.score - a.score);
78
+ const [best, second] = scores;
79
+ const total = scores.reduce((n, s) => n + s.score, 0);
80
+ // Por debajo de 3 puntos es una palabra suelta, no un documento de ese tipo.
81
+ if (best.score < 3)
82
+ return { type: "desconocido", confidence: 0, signals: [], runnerUp: best.score ? best.type : null };
83
+ return {
84
+ type: best.type,
85
+ confidence: Math.round((best.score / total) * 100) / 100,
86
+ signals: best.signals,
87
+ runnerUp: second.score ? second.type : null,
88
+ };
89
+ }
package/dist/dataset.d.ts CHANGED
@@ -2,7 +2,18 @@
2
2
  * Formatos de salida. Los nombres de campo son los que consumen los
3
3
  * entrenadores; cambiarlos rompe la carga del dataset, así que no se tocan.
4
4
  */
5
- export type DatasetFormat = "text" | "chat" | "prompt-completion";
5
+ export type DatasetFormat = "text" | "chat" | "prompt-completion"
6
+ /** Stanford Alpaca: instruction / input / output. Lo usan Axolotl, LLaMA-Factory y Unsloth. */
7
+ | "alpaca"
8
+ /** ShareGPT: conversations con from human / gpt. */
9
+ | "sharegpt"
10
+ /** Documentos de LangChain: page_content + metadata. */
11
+ | "langchain"
12
+ /** TextNode de LlamaIndex: id_ + text + metadata. */
13
+ | "llamaindex"
14
+ /** Para una base vectorial: id estable + text + metadata plana. */
15
+ | "embeddings";
16
+ export declare const DATASET_FORMATS: DatasetFormat[];
6
17
  export interface SourceDocument {
7
18
  /** Nombre del archivo: es la clave del reparto y de la trazabilidad. */
8
19
  name: string;
@@ -54,6 +65,19 @@ export declare function buildRecords(docs: SourceDocument[], options?: BuildOpti
54
65
  * campos que no conocen, así que dejarla puesta sale gratis y permite rastrear
55
66
  * de qué página salió cada ejemplo cuando el modelo diga algo raro.
56
67
  */
68
+ /**
69
+ * Tarjeta de dataset para Hugging Face (README.md con metadatos YAML). Un
70
+ * dataset sin tarjeta no dice de dónde salió ni qué se le quitó, y eso es justo
71
+ * lo que un equipo de ML necesita para confiar en él.
72
+ */
73
+ export declare function datasetCard(stats: DatasetStats, opts: {
74
+ name?: string;
75
+ format: DatasetFormat;
76
+ language?: string;
77
+ splits?: string[];
78
+ anonymized: boolean;
79
+ deduplicated?: boolean;
80
+ }): string;
57
81
  export declare function toJsonl(records: DatasetRecord[], withMeta?: boolean): string;
58
82
  export interface DatasetStats {
59
83
  records: number;
package/dist/dataset.js CHANGED
@@ -5,9 +5,11 @@
5
5
  // Eso exigiría mandar tus documentos a una API, que es exactamente lo que este
6
6
  // producto existe para evitar. Aquí todo sale del documento por reglas, y cada
7
7
  // registro dice de dónde salió.
8
+ import { contentId } from "./agentchunks.js";
8
9
  import { chunkMarkdown } from "./chunk.js";
9
10
  import { anonymize as redactPii } from "./anonymize.js";
10
11
  import { estimateTokens } from "./markdown.js";
12
+ export const DATASET_FORMATS = ["text", "chat", "prompt-completion", "alpaca", "sharegpt", "langchain", "llamaindex", "embeddings"];
11
13
  const DEFAULT_TEMPLATE = "Resume el contenido de «{heading}» del documento {document}.";
12
14
  /** Un trozo sin encabezado no puede pedir «resume la sección “archivo.pdf”». */
13
15
  const DEFAULT_TEMPLATE_NO_HEADING = "Resume el contenido del documento {document}.";
@@ -46,13 +48,30 @@ export function buildRecords(docs, options = {}) {
46
48
  const hasHeading = (chunk.heading ?? "").trim() !== "";
47
49
  const tpl = options.template ?? (hasHeading ? DEFAULT_TEMPLATE : DEFAULT_TEMPLATE_NO_HEADING);
48
50
  const instruction = fillTemplate(tpl, { heading: chunk.heading ?? "", document: doc.name });
49
- records.push({ data: shape(format, text, instruction, system), meta });
51
+ records.push({ data: shape(format, text, instruction, system, meta), meta });
50
52
  });
51
53
  }
52
54
  return records;
53
55
  }
54
- function shape(format, text, instruction, system) {
56
+ function shape(format, text, instruction, system, meta) {
57
+ const metadata = meta ? { source: meta.document, chunk: meta.chunk, heading: meta.heading, tokens: meta.tokens } : {};
55
58
  switch (format) {
59
+ case "alpaca":
60
+ return { instruction, input: "", output: text, ...(system ? { system } : {}) };
61
+ case "sharegpt":
62
+ return {
63
+ conversations: [
64
+ ...(system ? [{ from: "system", value: system }] : []),
65
+ { from: "human", value: instruction },
66
+ { from: "gpt", value: text },
67
+ ],
68
+ };
69
+ case "langchain":
70
+ return { page_content: text, metadata, type: "Document" };
71
+ case "llamaindex":
72
+ return { id_: contentId(text), text, metadata, class_name: "TextNode" };
73
+ case "embeddings":
74
+ return { id: contentId(text), text, metadata };
56
75
  case "text":
57
76
  return { text };
58
77
  case "prompt-completion":
@@ -75,6 +94,53 @@ function shape(format, text, instruction, system) {
75
94
  * campos que no conocen, así que dejarla puesta sale gratis y permite rastrear
76
95
  * de qué página salió cada ejemplo cuando el modelo diga algo raro.
77
96
  */
97
+ /**
98
+ * Tarjeta de dataset para Hugging Face (README.md con metadatos YAML). Un
99
+ * dataset sin tarjeta no dice de dónde salió ni qué se le quitó, y eso es justo
100
+ * lo que un equipo de ML necesita para confiar en él.
101
+ */
102
+ export function datasetCard(stats, opts) {
103
+ const size = stats.records < 1000 ? "n<1K" : stats.records < 10000 ? "1K<n<10K" : stats.records < 100000 ? "10K<n<100K" : "100K<n<1M";
104
+ const splits = opts.splits ?? ["train"];
105
+ return [
106
+ "---",
107
+ `language: [${opts.language ?? "es"}]`,
108
+ `size_categories: [${size}]`,
109
+ "tags: [sirdas, pdf, local-processing]",
110
+ "configs:",
111
+ " - config_name: default",
112
+ " data_files:",
113
+ ...splits.map((sp) => ` - split: ${sp}
114
+ path: ${sp}.jsonl`),
115
+ "---",
116
+ "",
117
+ `# ${opts.name ?? "Dataset"}`,
118
+ "",
119
+ "Generado con [Sırdaş](https://sirdas.app) en la máquina de quien lo creó: los documentos originales no se subieron a ningún servicio.",
120
+ "",
121
+ "| | |",
122
+ "|---|---|",
123
+ `| Formato | \`${opts.format}\` |`,
124
+ `| Registros | ${stats.records} |`,
125
+ `| Documentos de origen | ${stats.documents} |`,
126
+ `| Tokens (estimados) | ${stats.tokens} (mediana ${stats.medianTokens}, máx. ${stats.maxTokens}) |`,
127
+ `| Anonimizado | ${opts.anonymized ? `sí: ${stats.redacted} datos personales sustituidos en ${stats.recordsWithPii} registros` : "no"} |`,
128
+ `| Deduplicado | ${opts.deduplicated ? "sí (MinHash)" : "no"} |`,
129
+ `| Particiones | ${splits.join(", ")}${splits.length > 1 ? " — repartidas por documento, nunca por trozo, para que no se filtren a evaluación" : ""} |`,
130
+ "",
131
+ "## Uso",
132
+ "",
133
+ "```python",
134
+ "from datasets import load_dataset",
135
+ 'ds = load_dataset("json", data_files={' + splits.map((sp) => `"${sp}": "${sp}.jsonl"`).join(", ") + "})",
136
+ "```",
137
+ "",
138
+ "## Límites",
139
+ "",
140
+ "La anonimización es por reglas (identificaciones, NIT, correos, teléfonos, direcciones, nombres con etiqueta) y puede dejar pasar formatos poco comunes. Revisa una muestra antes de publicar.",
141
+ "",
142
+ ].join("\n");
143
+ }
78
144
  export function toJsonl(records, withMeta = true) {
79
145
  return records
80
146
  .map((r) => JSON.stringify(withMeta ? { ...r.data, sirdas: r.meta } : r.data))
@@ -0,0 +1,16 @@
1
+ import type { DocType } from "./classify.js";
2
+ export interface ExtractedField {
3
+ /** Nombre estable en snake_case, pensado para JSON y hojas de cálculo. */
4
+ name: string;
5
+ value: string;
6
+ /** Página (base 1) donde apareció, si el Markdown traía marcas de página. */
7
+ page: number | null;
8
+ /** El fragmento que casó la regla: la cita para que una persona lo verifique. */
9
+ evidence: string;
10
+ /** Dato de una persona: se sustituye si se pide anonimizar. */
11
+ personal: boolean;
12
+ }
13
+ /** Tipos para los que hay reglas: el resto devuelve lista vacía, no un error. */
14
+ export declare const EXTRACTABLE_TYPES: DocType[];
15
+ /** Extrae los campos de un tipo. Mejor con Markdown generado con `pageMarkers: true`, para citar la página. */
16
+ export declare function extractFields(markdown: string, type: DocType): ExtractedField[];
@@ -0,0 +1,103 @@
1
+ import { PAGE_MARKER } from "./markdown.js";
2
+ const money = (v) => v.replace(/[\s$]/g, "");
3
+ const digits = (v) => v.replace(/[^\dkK-]/g, "");
4
+ const AMOUNT = String.raw `\$?\s*([\d]{1,3}(?:[.,\s]\d{3})*(?:[.,]\d{1,2})?)`;
5
+ const DATE = String.raw `(\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|\d{4}-\d{2}-\d{2}|\d{1,2}\s+de\s+[a-záéíóú]+\s+de\s+\d{4})`;
6
+ const NIT = String.raw `(\d{3}\.?\d{3}\.?\d{3}\s*-?\s*\d)`;
7
+ const RULES = {
8
+ factura: [
9
+ { name: "numero_factura", re: /factura[^\n]{0,40}?(?:n[oº°.]*|número|numero)\s*[:.]?\s*([A-Z]{0,6}-?\d{1,12})/i },
10
+ { name: "cufe", re: /\bcufe\s*[:.]?\s*([0-9a-f]{8,128})/i },
11
+ { name: "nit_emisor", re: new RegExp(String.raw `nit[^\n\d]{0,20}` + NIT, "i"), clean: digits },
12
+ { name: "fecha_emision", re: new RegExp(String.raw `fecha(?:\s+de)?\s*(?:emisi[oó]n|expedici[oó]n|factura)?\s*[:.]?\s*` + DATE, "i") },
13
+ { name: "fecha_vencimiento", re: new RegExp(String.raw `vencimiento\s*[:.]?\s*` + DATE, "i") },
14
+ { name: "subtotal", re: new RegExp(String.raw `subtotal\s*[:.]?\s*` + AMOUNT, "i"), clean: money },
15
+ { name: "iva", re: new RegExp(String.raw `\biva\b(?:\s*\d{1,2}\s*%)?\s*[:.]?\s*` + AMOUNT, "i"), clean: money },
16
+ // «Subtotal» también contiene «total»: se exige que no vaya pegado a otra palabra.
17
+ { name: "total", re: new RegExp(String.raw `(?<![a-záéíóú])total\s*(?:a\s+pagar|factura|general)?\s*[:.]?\s*` + AMOUNT, "i"), clean: money },
18
+ { name: "resolucion_dian", re: /resoluci[oó]n(?:\s+dian)?\s*(?:n[oº°.]*)?\s*[:.]?\s*(\d{8,20})/i },
19
+ ],
20
+ rut: [
21
+ { name: "nit", re: new RegExp(String.raw `(?:n[uú]mero\s+de\s+identificaci[oó]n\s+tributaria|\bnit\b)[^\n\d]{0,30}` + NIT, "i"), clean: digits },
22
+ { name: "razon_social", re: /raz[oó]n\s+social\s*[:.]?\s*([^\n]{3,80})/i },
23
+ { name: "direccion_seccional", re: /direcci[oó]n\s+seccional\s*[:.]?\s*([^\n]{3,60})/i },
24
+ { name: "actividad_principal", re: /actividad\s+principal\s*[:.]?\s*(\d{4})/i },
25
+ ],
26
+ cedula: [
27
+ { name: "numero_documento", re: /(?:n[uú]mero|c\.?\s?c\.?|c[eé]dula(?:\s+de\s+ciudadan[ií]a)?)\s*[:.]?\s*(\d{1,3}(?:\.\d{3}){1,3}|\d{6,10})\b/i, personal: true, clean: digits },
28
+ { name: "fecha_nacimiento", re: new RegExp(String.raw `fecha\s+de\s+nacimiento\s*[:.]?\s*` + DATE, "i"), personal: true },
29
+ { name: "fecha_expedicion", re: new RegExp(String.raw `fecha\s+(?:y\s+lugar\s+)?de\s+expedici[oó]n\s*[:.]?\s*` + DATE, "i") },
30
+ ],
31
+ historia_clinica: [
32
+ { name: "paciente", re: /[Pp]aciente\s*[:.]?\s*([A-ZÁÉÍÓÚÑ][a-záéíóúñ]+(?:[ \t]+[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+){1,4})/, personal: true },
33
+ { name: "documento_paciente", re: /(?:c\.?\s?c\.?|documento|identificaci[oó]n)\s*[:.]?\s*(\d{1,3}(?:\.\d{3}){1,3}|\d{6,10})\b/i, personal: true, clean: digits },
34
+ { name: "fecha_atencion", re: new RegExp(String.raw `fecha(?:\s+de)?\s*(?:atenci[oó]n|ingreso|consulta)?\s*[:.]?\s*` + DATE, "i") },
35
+ { name: "motivo_consulta", re: /motivo\s+de\s+consulta\s*[:.]?\s*([^\n]{3,160})/i },
36
+ { name: "diagnostico_cie10", re: /\b([A-TV-Z]\d{2}(?:\.?\d{1,2})?)\b(?=[^\n]{0,80}(?:diagn|cie|principal|relacionado)|\s*[-–:]\s*[A-ZÁÉÍÓÚ])/g, all: true },
37
+ ],
38
+ extracto_bancario: [
39
+ { name: "numero_cuenta", re: /(?:n[uú]mero\s+de\s+)?cuenta\s*(?:n[oº°.]*)?\s*[:.]?\s*([*\dX]{4,}[\d-]*)/i, personal: true },
40
+ { name: "periodo", re: new RegExp(String.raw `(?:periodo|per[ií]odo)\s*[:.]?\s*` + DATE + String.raw `(?:\s*(?:al|a|-)\s*)` + DATE, "i") },
41
+ { name: "saldo_anterior", re: new RegExp(String.raw `saldo\s+anterior\s*[:.]?\s*` + AMOUNT, "i"), clean: money },
42
+ { name: "saldo_final", re: new RegExp(String.raw `saldo\s+(?:final|actual|disponible|total)\s*[:.]?\s*` + AMOUNT, "i"), clean: money },
43
+ ],
44
+ contrato: [
45
+ { name: "tipo_contrato", re: /contrato\s+de\s+([a-záéíóúñ ]{4,60}?)(?:\s+(?:entre|n[oº°.]|celebrado|suscrito)|[\n.,])/i },
46
+ { name: "valor", re: new RegExp(String.raw `valor(?:\s+(?:total|del\s+contrato))?\s*[:.]?\s*(?:de\s+)?` + AMOUNT, "i"), clean: money },
47
+ { name: "plazo", re: /(?:plazo|duraci[oó]n|t[eé]rmino)(?:\s+de\s+ejecuci[oó]n)?\s*[:.]?\s*(?:de\s+)?(\d{1,3}\s*\(?[a-z ]*\)?\s*(?:d[ií]as|meses|años))/i },
48
+ { name: "fecha_firma", re: new RegExp(String.raw `(?:firma|suscribe|a\s+los)[^\n]{0,40}?` + DATE, "i") },
49
+ ],
50
+ certificado: [
51
+ { name: "entidad", re: /^([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ .&]{5,80})$/m },
52
+ { name: "fecha_expedicion", re: new RegExp(String.raw `(?:expedid[oa]|fecha)[^\n]{0,30}?` + DATE, "i") },
53
+ ],
54
+ };
55
+ /** Página de una posición: la última marca `<!-- page N -->` antes de ella. */
56
+ function pageAt(markdown, index) {
57
+ let page = null;
58
+ const re = new RegExp(PAGE_MARKER.source.replace("^", "").replace("$", ""), "g");
59
+ for (const m of markdown.matchAll(re)) {
60
+ if ((m.index ?? 0) > index)
61
+ break;
62
+ page = Number(m[1]);
63
+ }
64
+ return page;
65
+ }
66
+ function evidenceAround(text, index, length) {
67
+ const start = text.lastIndexOf("\n", index) + 1;
68
+ const endNl = text.indexOf("\n", index + length);
69
+ return text
70
+ .slice(start, endNl === -1 ? undefined : endNl)
71
+ .trim()
72
+ .slice(0, 160);
73
+ }
74
+ /** Tipos para los que hay reglas: el resto devuelve lista vacía, no un error. */
75
+ export const EXTRACTABLE_TYPES = Object.keys(RULES);
76
+ /** Extrae los campos de un tipo. Mejor con Markdown generado con `pageMarkers: true`, para citar la página. */
77
+ export function extractFields(markdown, type) {
78
+ const rules = RULES[type] ?? [];
79
+ const out = [];
80
+ for (const rule of rules) {
81
+ const re = rule.all ? new RegExp(rule.re.source, rule.re.flags.includes("g") ? rule.re.flags : rule.re.flags + "g") : rule.re;
82
+ const matches = rule.all ? [...markdown.matchAll(re)] : [markdown.match(re)].filter((m) => !!m);
83
+ const seen = new Set();
84
+ for (const m of matches) {
85
+ const raw = (m[1] ?? "").trim();
86
+ if (!raw)
87
+ continue;
88
+ const value = rule.clean ? rule.clean(raw) : raw.replace(/\s+/g, " ");
89
+ if (seen.has(value))
90
+ continue;
91
+ seen.add(value);
92
+ const index = m.index ?? 0;
93
+ out.push({
94
+ name: rule.name,
95
+ value,
96
+ page: pageAt(markdown, index),
97
+ evidence: evidenceAround(markdown, index, m[0].length),
98
+ personal: !!rule.personal,
99
+ });
100
+ }
101
+ }
102
+ return out;
103
+ }
@@ -0,0 +1,30 @@
1
+ export type SourceFormat = "pdf" | "docx" | "xlsx" | "pptx" | "odt" | "ods" | "odp" | "epub" | "html" | "csv" | "tsv" | "md" | "txt" | "json" | "eml" | "rtf" | "image" | "unknown";
2
+ export interface ConvertedDocument {
3
+ format: SourceFormat;
4
+ markdown: string;
5
+ /** Hojas, diapositivas o capítulos: la unidad natural de cada formato. */
6
+ parts: {
7
+ title: string;
8
+ markdown: string;
9
+ }[];
10
+ /** Tablas ya estructuradas (Excel, CSV, tablas de Word y HTML). */
11
+ tables: {
12
+ title: string;
13
+ rows: string[][];
14
+ }[];
15
+ warnings: string[];
16
+ }
17
+ /** Por contenido primero (la extensión miente a menudo), por nombre después. */
18
+ export declare function detectFormat(bytes: Uint8Array, filename?: string): SourceFormat;
19
+ export declare function rowsToMarkdown(rows: string[][]): string;
20
+ export declare function htmlToMarkdown(html: string): {
21
+ markdown: string;
22
+ tables: string[][][];
23
+ };
24
+ /** CSV con comillas, separador autodetectado (coma, punto y coma o tabulador). */
25
+ export declare function parseDelimited(text: string, delimiter?: string): string[][];
26
+ /**
27
+ * Convierte cualquier formato soportado a Markdown. PDF e imágenes no pasan
28
+ * por aquí: necesitan PDF.js y OCR, y quien llama ya los tiene.
29
+ */
30
+ export declare function convertToMarkdown(bytes: Uint8Array, filename?: string): ConvertedDocument;