OpceanAI commited on
Commit
3ef6bb3
·
verified ·
1 Parent(s): 7688052

Upload folder using huggingface_hub

Browse files
DESIGN.md ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DESIGN.md
2
+
3
+ Direccion visual del Space de PASITA. Referencia elegida por el usuario:
4
+ el panel oscuro naranja de `webml-community/lfm2-webgpu-kernels`.
5
+
6
+ ## Lectura de diseno
7
+
8
+ Herramienta de documentos para usuarios de Hugging Face, en lenguaje de panel de instrumento
9
+ oscuro de webml-community, con diales ENERGY 2 / RHYTHM 1 / MOTION 1.
10
+
11
+ - ENERGY 2: presencia clara en cabecera y boton principal, sin hero ni decoracion de landing.
12
+ - RHYTHM 1: una sola composicion de dos paneles, porque la tarea es una entrada y una salida.
13
+ - MOTION 1: solo estados hover, focus y el pulso del estado de carga.
14
+
15
+ ## Tokens
16
+
17
+ - Fondo `#050608` con gradiente radial calido/azul de identidad, texto `#f4f5f7`,
18
+ texto secundario `#9aa0ab`, texto terciario `#7f8698` (aclarado desde el `#6b7280` original
19
+ para cumplir AA), hairlines `rgba(255,255,255,0.14 / 0.08)`, paneles `rgba(255,255,255,0.03)`.
20
+ - Acento calido `#ff7a30` para la accion principal y el foco de estado; azul frio `#7aa0ff`
21
+ solo para el anillo de foco de teclado. Peligro `#ff7a6b`.
22
+ - Tipografia: Hanken Grotesk para lectura, JetBrains Mono para etiquetas, contadores y estado.
23
+
24
+ ## Por que cada decision
25
+
26
+ - Fondo casi negro: la tarea es leer documentos; el color compite con el contenido.
27
+ - Un solo acento calido: marca la accion y el estado activo; no hay acento decorativo repartido.
28
+ - Mono solo para maquina: etiquetas, contadores, metricas y estado. La prosa usa Hanken Grotesk.
29
+ - Dos paneles: la conversion es entrada -> salida; en movil se apilan.
30
+ - Linea de estado con metricas reales: prueba la promesa de fidelidad (numeros conservados,
31
+ cobertura, ruta usada) en lugar de afirmarla.
32
+ - Gradiente ambiente estatico y cabecera con blur unico: identidad de la referencia, sin
33
+ animaciones perpetuas ni glassmorphism generalizado.
34
+ - Controles reales: Convertir, Limpiar, candidatos 1 a 3 y conversor determinista. Ningun
35
+ elemento decorativo sin funcion.
36
+
37
+ ## Contraste (WCAG AA)
38
+
39
+ Calculado con la formula de luminancia relativa:
40
+
41
+ - `#9aa0ab` sobre `#050608`: 7.65:1 (texto normal, pasa).
42
+ - `#7f8698` sobre `#050608`: 5.54:1 (texto normal, pasa).
43
+ - `#ff7a30` sobre `#050608`: 7.77:1 (texto y acento, pasa).
44
+ - Texto `#0b0705` sobre boton `#ff7a30`: 7.48:1 (pasa).
45
+ - Anillo de foco `#7aa0ff` sobre `#050608`: 7.96:1 (no textual, supera 3:1).
46
+
47
+ ## Estados
48
+
49
+ - Vacio: el panel de salida muestra un marcador y el estado dice READY.
50
+ - Carga: Gradio muestra el progreso en el boton; el estado cambia con la ejecucion terminada.
51
+ - Error: el estado pasa a ERROR con el tipo de excepcion y la salida muestra el mensaje.
52
+ - Teclado: todo control es alcanzable, `:focus-visible` visible en azul.
53
+
54
+ ## Lo que no se hace
55
+
56
+ - Sin emojis en el chrome de la interfaz.
57
+ - Sin capturas falsas, testimonios ni cifras inventadas.
58
+ - Sin animaciones en bucle ni glassmorphism en varias capas.
59
+ - Sin rejillas o patrones decorativos de fondo.
README.md CHANGED
@@ -1,51 +1,66 @@
1
  ---
2
- title: PASITA — Plain Text to Markdown
3
  emoji: 📝
4
- colorFrom: indigo
5
- colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.26.0
8
  app_file: app.py
9
- short_description: Faithful text-to-Markdown demo with the 88M PASITA model
10
  python_version: "3.12"
11
  ---
12
 
13
- # PASITA — plain text to faithful Markdown
14
 
15
- Interactive demo for [**OpceanAI/PASITA**](https://huggingface.co/OpceanAI/PASITA): an
16
- 88M-parameter decoder-only language model **trained 100% from scratch** (no base model),
17
- specialized in converting **plain text into valid Markdown while preserving information**.
18
 
19
- Compiler behavior, not chatbot behavior: it adds structure, it does not invent content.
 
20
 
21
- ## What this Space does
22
 
23
- - Paste plain text (OCR output, pasted HTML, meeting notes, tutorials, reports…) and get
24
- GitHub-Flavored Markdown back.
25
- - Shows the **rendered Markdown**; use the copy button on the output to grab the raw source.
26
- - Greedy decoding (`do_sample=False`) for faithful, reproducible output.
27
- - Exposed as an **MCP server** — every visitor can call the `convert` tool with an agent.
28
 
29
- ## Valid regime & limitations
 
 
 
 
 
 
 
30
 
31
- Works best on medium/long documents. It is fragile on 1–3 line inputs, and may truncate
32
- digits, drop secondary data, emit echo H1s, or continue past completion. For best results
33
- give it the whole page.
 
 
 
 
 
34
 
35
  ## Model
36
 
37
  | | |
38
  |---|---|
39
- | Parameters | 88,099,584 (~88M), bf16 |
40
- | Architecture | `LlamaForCausalLM`, 12 layers, hidden 768, GQA 12Q/4KV |
41
- | Context | 2048 tokens, RoPE theta 100000 |
42
- | Tokenizer | custom 16k byte-level BPE (ES/EN) |
43
- | Training | SFT + DPO + GRPO, 80M human markdown-derived tokens |
44
  | License | Apache-2.0 |
45
 
46
- Benchmarks on the held-out PASITA-bench-1000 (self-reported): GFM validity 0.956,
47
  faithfulness 0.927, semantic faithfulness 0.888, table fidelity 1.000.
48
 
 
 
 
 
 
 
49
  ## Links
50
 
51
  - Model card: https://huggingface.co/OpceanAI/PASITA
 
 
1
  ---
2
+ title: "PASITA: plain text to Markdown"
3
  emoji: 📝
4
+ colorFrom: gray
5
+ colorTo: red
6
  sdk: gradio
7
  sdk_version: 6.26.0
8
  app_file: app.py
9
+ short_description: Faithful text to Markdown with the 88M PASITA model
10
  python_version: "3.12"
11
  ---
12
 
13
+ # PASITA: plain text to faithful Markdown
14
 
15
+ Demo Space for [**OpceanAI/PASITA**](https://huggingface.co/OpceanAI/PASITA), an 88M-parameter
16
+ decoder-only language model trained from scratch for one task: turning plain text into valid
17
+ Markdown without inventing content. It adds structure, it does not rewrite the source.
18
 
19
+ Paste OCR output, pasted HTML, meeting notes, a report or an article, and copy the result from
20
+ the rendered view or the raw source view.
21
 
22
+ ## How this Space converts
23
 
24
+ The app runs a small harness around the model instead of calling it once:
 
 
 
 
25
 
26
+ 1. Free-form prose goes to PASITA. Three samples are generated with a fixed seed
27
+ (`temperature 0.3`, adaptive length), each one is post-processed, and the reranker keeps the
28
+ best candidate by number fidelity, content coverage, list and table preservation and GFM validity.
29
+ 2. Inputs detected as HTML or as a delimited data dump are converted with a deterministic
30
+ converter. The model does not handle raw markup or table synthesis at this size, and the demo
31
+ does not pretend otherwise.
32
+ 3. The status line under the header reports what actually happened for each run: which path ran,
33
+ how many of the source numbers survived, content coverage and elapsed time.
34
 
35
+ The same pipeline is available over the API and as an MCP tool through the `convert` endpoint.
36
+
37
+ ## What works, what does not
38
+
39
+ Valid regime: medium and long documents, in Spanish or English. The model is fragile on 1 to 3
40
+ line inputs, and it can truncate digits, drop secondary data or continue past completion. The
41
+ harness cuts degenerate continuations and repair headings, but it cannot add information the
42
+ model never generated.
43
 
44
  ## Model
45
 
46
  | | |
47
  |---|---|
48
+ | Parameters | 88,099,584 (~88M) in bfloat16 |
49
+ | Architecture | `LlamaForCausalLM`, 12 layers, hidden 768, GQA 12Q/4KV, ctx 2048 |
50
+ | Tokenizer | custom 16k byte-level BPE |
51
+ | Training | SFT + DPO + GRPO on markdown-derived ES/EN data |
 
52
  | License | Apache-2.0 |
53
 
54
+ Benchmarks on the held-out PASITA-bench-1000, as reported on the model card: GFM validity 0.956,
55
  faithfulness 0.927, semantic faithfulness 0.888, table fidelity 1.000.
56
 
57
+ ## Files
58
+
59
+ - `app.py`: Gradio interface and ZeroGPU handler.
60
+ - `pasita.py`: prompt, generation, post-processing, scoring and reranking harness.
61
+ - `DESIGN.md`: visual direction, motion settings and the reason for each decision.
62
+
63
  ## Links
64
 
65
  - Model card: https://huggingface.co/OpceanAI/PASITA
66
+ - API: append `?view=api` to this Space URL.
__pycache__/app.cpython-310.pyc CHANGED
Binary files a/__pycache__/app.cpython-310.pyc and b/__pycache__/app.cpython-310.pyc differ
 
__pycache__/pasita.cpython-310.pyc ADDED
Binary file (35.8 kB). View file
 
app.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import time
2
 
3
  import spaces
@@ -5,107 +6,144 @@ import torch
5
  import gradio as gr
6
  from transformers import AutoModelForCausalLM, AutoTokenizer
7
 
 
 
8
  MODEL_ID = "OpceanAI/PASITA"
9
- PROMPT_TEMPLATE = "CONVIERTE A MARKDOWN:\n{text}\n\n### Markdown:\n"
10
- MAX_PROMPT_TOKENS = 1024
11
- MAX_NEW_TOKENS_HARD_CAP = 896
12
 
13
  tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
 
14
  model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype="auto").to("cuda")
15
  model.eval()
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
- def _duration(text, *args, **kwargs):
19
- return min(45, max(15, 10 + len(text or "") // 80))
20
 
 
 
 
 
21
 
22
- @spaces.GPU(duration=_duration)
23
- def to_markdown(text: str, max_new_tokens: int = 0) -> str:
24
- """Convert plain text into faithful, valid GitHub-Flavored Markdown.
25
 
26
- PASITA adds structure (headings, lists, tables, emphasis) but does not
27
- invent content. Works best on medium/long documents such as OCR output,
28
- pasted HTML, meeting notes, tutorials or reports.
 
 
 
 
29
 
30
  Args:
31
- text: The plain text document to convert to Markdown.
32
- max_new_tokens: Output length cap. 0 (default) picks a length automatically.
 
33
  Returns:
34
- The document converted to Markdown.
35
  """
36
  text = (text or "").strip()
37
  if not text:
38
- raise gr.Error("Paste some plain text to convert.")
39
-
40
- if max_new_tokens and max_new_tokens > 0:
41
- max_new = min(MAX_NEW_TOKENS_HARD_CAP, int(max_new_tokens))
42
- else:
43
- max_new = min(MAX_NEW_TOKENS_HARD_CAP, max(128, len(text) // 2))
44
-
45
- prompt = PROMPT_TEMPLATE.format(text=text)
46
- enc = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=MAX_PROMPT_TOKENS)
47
- enc = {key: value.to(model.device) for key, value in enc.items()}
48
-
49
- t0 = time.perf_counter()
50
- with torch.inference_mode():
51
- out = model.generate(
52
- **enc,
53
- max_new_tokens=max_new,
54
- do_sample=False,
55
- use_cache=False,
56
- pad_token_id=tokenizer.pad_token_id,
57
- )
58
- print(
59
- f"[PASITA] {enc['input_ids'].shape[1]} prompt tokens -> {max_new} max new tokens "
60
- f"in {time.perf_counter() - t0:.2f}s",
61
- flush=True,
62
- )
63
 
64
- generated = out[0][enc["input_ids"].shape[1]:]
65
- return tokenizer.decode(generated, skip_special_tokens=True).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
 
 
 
 
 
 
 
67
 
68
- CSS = """
69
- #col-container { max-width: 1200px; margin: 0 auto; }
70
- .dark .gradio-container { color: var(--body-text-color); }
71
- """
72
 
73
- INTRO = """
74
- # PASITA — plain text to faithful Markdown
75
 
76
- [**PASITA v1**](https://huggingface.co/OpceanAI/PASITA) is an 88M-parameter decoder-only
77
- language model **trained 100% from scratch** for one task: converting plain text into valid
78
- Markdown **while preserving the information**. Compiler behavior, not chatbot behavior: it adds
79
- structure, it does not invent content.
80
 
81
- Paste OCR output, pasted HTML, meeting notes, reports or tutorials below.
 
 
 
 
 
 
 
 
 
 
82
  """
83
 
84
- FOOTER = """
85
- **Valid regime:** medium/long documents (OCR output, pasted HTML, meeting notes, tutorials).
86
- Fragile on 1–3 line inputs.
87
-
88
- **Known limitations:** may truncate digits, drop secondary data, emit echo H1s, or continue
89
- past completion. Decoding is greedy (`do_sample=False`) to stay faithful to the source.
 
 
 
90
  """
91
 
92
  EXAMPLES = [
93
  [
94
- """INFORME TRIMESTRAL — VENTAS Q2 2026
95
 
96
  Resumen ejecutivo
97
- Las ventas netas alcanzaron 12,4 millones de euros, un 8,3% más que en el mismo periodo del año anterior. El margen bruto se situó en el 41,2%.
98
 
99
- Resultados por región
100
- Norteamérica: 5,1 M€ (+12,0%)
101
- Europa: 4,6 M€ (+4,8%)
102
- Latinoamérica: 2,7 M€ (+6,1%)
103
 
104
- Notas
105
- Los pedidos pendientes al cierre del trimestre sumaban 1.842 unidades. La previsión para Q3 es de 13,1 M€."""
106
  ],
107
  [
108
- """Team sync — 2026-08-14
109
  Attendees: Ana, Marc, Priya, Tom
110
 
111
  Decisions
@@ -118,64 +156,269 @@ Open questions
118
  - Who signs off on the SOC2 evidence?"""
119
  ],
120
  [
121
- """<h2>Shipping policy</h2><p>Orders placed before 2:00 PM CET ship the same business day.</p><ul><li>Standard delivery: 3–5 business days</li><li>Express delivery: 1 business day</li><li>Free shipping over €49</li></ul><p>Returns are accepted within 30 days with the original packaging.</p>"""
122
  ],
123
  [
124
- """Regional results 2025
125
- Region | Revenue | Growth
126
- North America | 4.2M | +12%
127
- Europe | 3.8M | +7%
128
- APAC | 2.1M | +19%
129
- Note: APAC growth driven by Japan and Vietnam."""
 
 
 
 
 
 
 
 
 
 
130
  ],
131
  ]
132
 
133
- with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="PASITA — plain text to Markdown") as demo:
134
- with gr.Column(elem_id="col-container"):
135
- gr.Markdown(INTRO)
136
- with gr.Row():
137
- with gr.Column():
138
- text_in = gr.Textbox(
139
- label="Plain text",
140
- placeholder="Paste OCR output, HTML, meeting notes, a report or a tutorial…",
141
- lines=16,
142
- max_lines=32,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  )
144
- with gr.Accordion("Generation settings", open=False):
145
- max_new = gr.Slider(
146
- minimum=0,
147
- maximum=896,
148
- value=0,
149
- step=32,
150
- label="Max new tokens",
151
- info="0 lets PASITA choose the length automatically.",
152
- )
153
- with gr.Row():
154
- run = gr.Button("Convert to Markdown", variant="primary")
155
- clear = gr.Button("Clear")
156
- with gr.Column():
157
- md_out = gr.Markdown(
158
- label="Markdown",
159
- value="_The converted Markdown will appear here._",
160
- buttons=["copy"],
161
- container=True,
162
- padding=True,
163
- height=560,
164
  )
165
- gr.Examples(
166
- examples=EXAMPLES,
167
- inputs=[text_in],
168
- outputs=[md_out],
169
- fn=to_markdown,
170
- cache_examples=True,
171
- cache_mode="lazy",
172
- examples_per_page=4,
173
- api_name=False,
174
- )
175
- gr.Markdown(FOOTER)
 
 
 
 
 
 
 
 
 
 
 
176
 
177
- run.click(to_markdown, inputs=[text_in, max_new], outputs=md_out, api_name="convert")
178
- text_in.submit(to_markdown, inputs=[text_in, max_new], outputs=md_out, api_name=False)
179
- clear.click(lambda: (None, None), inputs=None, outputs=[text_in, md_out], api_name=False)
 
180
 
181
- demo.launch(mcp_server=True)
 
 
1
+ import html
2
  import time
3
 
4
  import spaces
 
6
  import gradio as gr
7
  from transformers import AutoModelForCausalLM, AutoTokenizer
8
 
9
+ from pasita import infer_kind, convert_to_markdown
10
+
11
  MODEL_ID = "OpceanAI/PASITA"
12
+ MODEL_CARD = "https://huggingface.co/OpceanAI/PASITA"
 
 
13
 
14
  tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
15
+ tokenizer.padding_side = "left"
16
  model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype="auto").to("cuda")
17
  model.eval()
18
 
19
+ EMPTY_MD = "_The converted Markdown appears here. Paste a document on the left and press Convert._"
20
+
21
+ GLYPH = (
22
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
23
+ 'stroke-linecap="round" aria-hidden="true">'
24
+ '<path d="M9 4 7 20M17 4l-2 16M4 9h16M3 15h16"/></svg>'
25
+ )
26
+
27
+
28
+ def _status(text: str, state: str = "ready", detail: str = "") -> str:
29
+ detail_html = f'<span class="p-detail">{html.escape(detail)}</span>' if detail else ""
30
+ return (
31
+ f'<div class="p-status {state}"><span class="p-dot"></span>'
32
+ f'<span class="p-text">{html.escape(text)}</span>{detail_html}</div>'
33
+ )
34
+
35
+
36
+ def _counter(text: str) -> str:
37
+ text = text or ""
38
+ words = len(text.split())
39
+ return (
40
+ '<span class="p-label">PLAIN TEXT</span>'
41
+ f'<span class="p-count">{len(text):,} CHARS / {words:,} WORDS</span>'
42
+ )
43
 
 
 
44
 
45
+ def _estimate_duration(text, num_candidates=3, structured_fallback=True, *args, **kwargs):
46
+ if structured_fallback and infer_kind((text or "").strip()) in ("html", "data"):
47
+ return 10
48
+ return min(60, max(20, 14 + len(text or "") // 40 + 6 * (int(num_candidates) - 1)))
49
 
 
 
 
50
 
51
+ @spaces.GPU(duration=_estimate_duration)
52
+ def convert(text: str, num_candidates: int = 3, structured_fallback: bool = True) -> tuple[str, str, str]:
53
+ """Convert a plain-text document into faithful GitHub-Flavored Markdown.
54
+
55
+ PASITA samples the document several times, every candidate is post-processed and
56
+ scored for number fidelity, content coverage and GFM validity, and the best one is
57
+ returned. Inputs detected as HTML or delimited data are converted deterministically.
58
 
59
  Args:
60
+ text: The document to convert, for example OCR output, pasted HTML, meeting notes or a report.
61
+ num_candidates: How many PASITA samples to generate and rerank, from 1 to 3.
62
+ structured_fallback: Use the deterministic converter for HTML and delimited data.
63
  Returns:
64
+ A tuple of the rendered Markdown, the raw Markdown and a status line.
65
  """
66
  text = (text or "").strip()
67
  if not text:
68
+ return EMPTY_MD, "", _status("READY / PASTE A DOCUMENT TO CONVERT")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
+ started = time.perf_counter()
71
+ try:
72
+ result = convert_to_markdown(
73
+ model,
74
+ tokenizer,
75
+ text,
76
+ num_candidates=max(1, min(3, int(num_candidates))),
77
+ do_sample=True,
78
+ temperature=0.3,
79
+ top_p=0.95,
80
+ seed=0,
81
+ use_cache=True,
82
+ structured_fallback=bool(structured_fallback),
83
+ skip_model_for_structured=True,
84
+ )
85
+ except Exception as exc:
86
+ message = html.escape(f"{type(exc).__name__}: {exc}")
87
+ return (
88
+ f"> Conversion failed.\n>\n> `{message}`",
89
+ "",
90
+ _status(f"ERROR / {type(exc).__name__}", "error", f"{time.perf_counter() - started:.1f}S"),
91
+ )
92
 
93
+ metrics = result["metrics"]
94
+ numbers = f"{metrics['numbers_matched']}/{metrics['numbers_in']} NUMBERS"
95
+ overlap = f"{metrics['content_overlap']:.0%} CONTENT"
96
+ source = "HTML/TABLE CONVERTER" if result.get("strategy", {}).get("mode") == "structured" else "PASITA + RERANK"
97
+ taken = result["seconds"] if result["seconds"] else time.perf_counter() - started
98
+ detail = f"{source} / {numbers} / {overlap} / {taken:.1f}S"
99
+ return result["markdown"], result["markdown"], _status("DONE", "ready", detail)
100
 
 
 
 
 
101
 
102
+ def reset() -> tuple[str, str, str, str]:
103
+ return EMPTY_MD, "", _status("READY"), _counter("")
104
 
 
 
 
 
105
 
106
+ HEADER = f"""
107
+ <header class="p-head">
108
+ <div class="p-brand">
109
+ <span class="p-glyph">{GLYPH}</span>
110
+ <span class="p-name">PASITA</span>
111
+ <span class="p-tag">/ 88M FROM SCRATCH</span>
112
+ </div>
113
+ <div class="p-actions">
114
+ <a class="p-btn" href="{MODEL_CARD}" target="_blank" rel="noopener">MODEL CARD</a>
115
+ </div>
116
+ </header>
117
  """
118
 
119
+ FOOTER = f"""
120
+ <footer class="p-foot">
121
+ <span>PASITA v1 converts plain text to Markdown, in Spanish and English. It works best on medium and long documents.</span>
122
+ <span>
123
+ <a href="{MODEL_CARD}" target="_blank" rel="noopener">MODEL</a> /
124
+ <a href="?view=api" target="_blank" rel="noopener">API</a> /
125
+ RUNS ON ZEROGPU
126
+ </span>
127
+ </footer>
128
  """
129
 
130
  EXAMPLES = [
131
  [
132
+ """RESUMEN MENSUAL DE OPERACIONES: MARZO 2026
133
 
134
  Resumen ejecutivo
135
+ Los pedidos gestionados alcanzaron 18.420 unidades, un 6,1% más que en febrero. El tiempo medio de preparación bajó a 2,4 horas.
136
 
137
+ Detalle por almacén
138
+ Madrid: 8.120 pedidos (+7,4%)
139
+ Barcelona: 5.480 pedidos (+3,9%)
140
+ Lisboa: 4.820 pedidos (+6,7%)
141
 
142
+ Incidencias
143
+ Se registraron 96 incidencias de embalaje, el 0,52% del total. La previsión para abril es de 19.100 pedidos."""
144
  ],
145
  [
146
+ """Team sync, 2026-08-14
147
  Attendees: Ana, Marc, Priya, Tom
148
 
149
  Decisions
 
156
  - Who signs off on the SOC2 evidence?"""
157
  ],
158
  [
159
+ """<h2>Shipping policy</h2><p>Orders placed before 2:00 PM CET ship the same business day.</p><ul><li>Standard delivery: 3 to 5 business days</li><li>Express delivery: 1 business day</li><li>Free shipping over 49 EUR</li></ul><p>Returns are accepted within 30 days with the original packaging.</p>"""
160
  ],
161
  [
162
+ """year,region,revenue,growth
163
+ 2019,North America,5120.4,4.1%
164
+ 2019,Europe,4380.9,3.7%
165
+ 2019,Latin America,2105.2,2.9%
166
+ 2020,North America,4875.6,-4.8%
167
+ 2020,Europe,4210.3,-3.9%
168
+ 2020,Latin America,1988.1,-5.6%
169
+ 2021,North America,5402.7,10.8%
170
+ 2021,Europe,4605.5,9.4%
171
+ 2021,Latin America,2240.8,12.7%
172
+ 2022,North America,5610.2,3.8%
173
+ 2022,Europe,4720.6,2.5%
174
+ 2022,Latin America,2385.4,6.5%
175
+ 2023,North America,5894.9,5.1%
176
+ 2023,Europe,4903.7,3.9%
177
+ 2023,Latin America,2560.3,7.3%"""
178
  ],
179
  ]
180
 
181
+ CSS = """
182
+ @import url('https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
183
+
184
+ :root, .gradio-container {
185
+ --bg:#050608; --ink:#f4f5f7; --muted:#9aa0ab; --muted-2:#7f8698;
186
+ --warm:#ff7a30; --cool:#7aa0ff; --obsidian:#0a0a0c;
187
+ --hair:rgba(255,255,255,0.14); --hair-soft:rgba(255,255,255,0.08);
188
+ --panel:rgba(255,255,255,0.03); --panel-hover:rgba(255,255,255,0.06);
189
+ --danger:#ff7a6b;
190
+ --font-display:"Hanken Grotesk", system-ui, sans-serif;
191
+ --font-mono:"JetBrains Mono", ui-monospace, monospace;
192
+
193
+ --body-background-fill:transparent;
194
+ --background-fill-primary:transparent;
195
+ --background-fill-secondary:transparent;
196
+ --block-background-fill:transparent;
197
+ --block-border-color:transparent;
198
+ --block-border-width:0px;
199
+ --block-radius:0px;
200
+ --border-color-primary:var(--hair);
201
+ --body-text-color:var(--ink);
202
+ --body-text-color-subdued:var(--muted);
203
+ --input-background-fill:transparent;
204
+ --input-border-color:transparent;
205
+ --input-radius:12px;
206
+ --color-accent:var(--warm);
207
+ --color-accent-soft:rgba(255,122,48,0.15);
208
+ --button-primary-background-fill:var(--warm);
209
+ --button-primary-background-fill-hover:#ff9757;
210
+ --button-primary-text-color:#0b0705;
211
+ --button-secondary-background-fill:transparent;
212
+ --button-secondary-text-color:var(--muted);
213
+ --button-secondary-border-color:var(--hair);
214
+ --button-large-radius:100px;
215
+ --button-small-radius:100px;
216
+ --shadow-drop:none; --shadow-drop-lg:none;
217
+ --font:var(--font-display); --font-mono:var(--font-mono);
218
+ }
219
+
220
+ body, .gradio-container {
221
+ background:
222
+ radial-gradient(120% 80% at 50% 118%, rgba(255,110,40,0.10), rgba(255,110,40,0) 55%),
223
+ radial-gradient(90% 70% at 88% -10%, rgba(80,120,230,0.10), rgba(80,120,230,0) 55%),
224
+ radial-gradient(140% 120% at 50% 50%, #0a0c11 0%, #050608 55%, #030305 100%) !important;
225
+ background-attachment: fixed !important;
226
+ color: var(--ink);
227
+ font-family: var(--font-display);
228
+ -webkit-font-smoothing: antialiased;
229
+ }
230
+ .gradio-container { max-width: 1240px !important; margin: 0 auto; padding: 0 20px 26px; }
231
+ footer { display: none !important; }
232
+ :focus-visible { outline: 2px solid var(--cool) !important; outline-offset: 2px; }
233
+
234
+ .p-head {
235
+ display: flex; align-items: center; justify-content: space-between; gap: 16px;
236
+ padding: 22px 0 16px; border-bottom: 1px solid var(--hair-soft); margin-bottom: 20px;
237
+ }
238
+ .p-brand { display: flex; align-items: center; gap: 10px; }
239
+ .p-glyph { display: inline-flex; width: 22px; height: 22px; color: var(--warm); }
240
+ .p-glyph svg { width: 100%; height: 100%; }
241
+ .p-name { font-weight: 700; letter-spacing: 0.14em; font-size: 1.02rem; }
242
+ .p-tag { font-family: var(--font-mono); font-size: 0.62rem; letter-spacing: 0.14em; color: var(--muted-2); margin-top: 2px; }
243
+ .p-actions { display: flex; align-items: center; gap: 10px; }
244
+ .p-btn {
245
+ font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.1em;
246
+ color: var(--ink); text-decoration: none; padding: 0 18px; min-height: 44px;
247
+ display: inline-flex; align-items: center; border-radius: 999px;
248
+ background: rgba(255,255,255,0.04); border: 1px solid var(--hair);
249
+ transition: background .25s ease, color .25s ease, border-color .25s ease;
250
+ }
251
+ .p-btn:hover { background: var(--ink); color: #08090c; border-color: var(--ink); }
252
+
253
+ .p-status {
254
+ display: flex; align-items: center; gap: 9px; margin: -6px 0 16px;
255
+ font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em;
256
+ text-transform: uppercase; color: var(--muted); min-height: 20px;
257
+ }
258
+ .p-dot { width: 7px; height: 7px; border-radius: 50%; background: rgba(255,255,255,0.2); flex-shrink: 0; }
259
+ .p-status.ready .p-dot { background: var(--warm); box-shadow: 0 0 10px rgba(255,138,77,0.55); }
260
+ .p-status.busy .p-dot { background: #ffcd6b; animation: p-pulse 1.1s ease-in-out infinite; }
261
+ .p-status.error { color: var(--danger); }
262
+ .p-status.error .p-dot { background: var(--danger); box-shadow: 0 0 10px rgba(255,122,107,0.5); }
263
+ .p-detail { margin-left: auto; color: var(--muted-2); }
264
+ @keyframes p-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
265
+
266
+ #p-shell { gap: 18px; align-items: stretch; }
267
+ .p-panel {
268
+ background: var(--panel); border: 1px solid var(--hair); border-radius: 16px;
269
+ padding: 14px 14px 12px; min-width: 0;
270
+ }
271
+ .p-label { font-family: var(--font-mono); font-size: 0.66rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); }
272
+ .p-count { float: right; font-family: var(--font-mono); font-size: 0.66rem; letter-spacing: 0.04em; color: var(--muted-2); }
273
+
274
+ #p-text, #p-text > div { background: transparent !important; border: 0 !important; box-shadow: none !important; }
275
+ #p-text textarea {
276
+ background: transparent !important; border: 0 !important; color: var(--ink) !important;
277
+ font-family: var(--font-display) !important; font-size: 0.95rem; line-height: 1.65;
278
+ min-height: 360px; padding: 12px 2px 0;
279
+ }
280
+ #p-text textarea::placeholder { color: var(--muted-2) !important; }
281
+
282
+ #p-run, #p-clear { font-family: var(--font-mono) !important; font-size: 0.75rem !important; letter-spacing: 0.12em; min-height: 46px; }
283
+ #p-run { flex: 2; }
284
+ #p-clear { flex: 1; background: transparent !important; color: var(--muted) !important; border: 1px solid var(--hair) !important; }
285
+ #p-clear:hover { color: var(--ink) !important; border-color: rgba(255,255,255,0.3) !important; }
286
+
287
+ #p-examples table { border-collapse: separate; border-spacing: 6px; }
288
+ #p-examples button {
289
+ background: var(--panel) !important; border: 1px solid var(--hair) !important; border-radius: 999px !important;
290
+ color: var(--muted) !important; font-size: 12px !important; min-height: 40px; padding: 8px 14px !important;
291
+ text-align: left; transition: background .2s ease, color .2s ease, border-color .2s ease;
292
+ }
293
+ #p-examples button:hover { border-color: rgba(255,255,255,0.3) !important; color: var(--ink) !important; background: var(--panel-hover) !important; }
294
+ #p-examples label { font-family: var(--font-mono) !important; font-size: 0.66rem !important; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted) !important; }
295
+
296
+ #p-tabs .tab-nav {
297
+ display: inline-flex; gap: 4px; background: var(--panel); border: 1px solid var(--hair);
298
+ border-radius: 100px; padding: 3px; margin-bottom: 12px;
299
+ }
300
+ #p-tabs .tab-nav button {
301
+ appearance: none; border: 0 !important; background: transparent !important; border-radius: 100px !important;
302
+ padding: 7px 14px !important; font-family: var(--font-mono) !important; font-size: 10.5px !important;
303
+ letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted) !important;
304
+ }
305
+ #p-tabs .tab-nav button.selected, #p-tabs .tab-nav button[aria-selected="true"] {
306
+ background: rgba(255,255,255,0.08) !important; color: var(--warm) !important;
307
+ }
308
+
309
+ #p-rendered { min-height: 360px; font-size: 0.95rem; line-height: 1.72; color: var(--ink); }
310
+ #p-rendered h1, #p-rendered h2, #p-rendered h3, #p-rendered h4 { color: #fff; font-weight: 600; line-height: 1.25; margin: 1.25em 0 0.55em; }
311
+ #p-rendered h1 { font-size: 1.5em; }
312
+ #p-rendered h2 { font-size: 1.28em; }
313
+ #p-rendered h3 { font-size: 1.12em; }
314
+ #p-rendered p { margin: 0 0 0.9em; }
315
+ #p-rendered ul, #p-rendered ol { margin: 0 0 0.9em; padding-left: 1.5em; }
316
+ #p-rendered li { margin: 0.25em 0; }
317
+ #p-rendered li::marker { color: var(--muted-2); }
318
+ #p-rendered a { color: #8ab4ff; text-decoration: underline; text-underline-offset: 2px; }
319
+ #p-rendered a:hover { color: #aecbff; }
320
+ #p-rendered blockquote { margin: 0 0 0.9em; padding: 2px 0 2px 16px; border-left: 2px solid var(--hair); color: var(--muted); }
321
+ #p-rendered hr { border: 0; border-top: 1px solid var(--hair); margin: 1.3em 0; }
322
+ #p-rendered code { font-family: var(--font-mono); font-size: 0.85em; background: var(--panel); border: 1px solid var(--hair); border-radius: 5px; padding: 1px 5px; }
323
+ #p-rendered pre { margin: 0 0 0.9em; padding: 14px 16px; background: var(--obsidian); border: 1px solid var(--hair); border-radius: 12px; overflow-x: auto; }
324
+ #p-rendered pre code { background: none; border: 0; padding: 0; font-size: 0.82em; line-height: 1.6; }
325
+ #p-rendered table { border-collapse: collapse; margin: 0 0 0.9em; font-size: 0.92em; display: block; overflow-x: auto; }
326
+ #p-rendered th, #p-rendered td { border: 1px solid var(--hair); padding: 6px 11px; text-align: left; }
327
+ #p-rendered th { background: var(--panel); color: var(--ink); font-weight: 600; }
328
+
329
+ #p-source {
330
+ background: var(--obsidian) !important; border: 1px solid var(--hair-soft) !important;
331
+ border-radius: 12px !important; font-family: var(--font-mono) !important; min-height: 360px;
332
+ }
333
+
334
+ #p-settings { background: transparent !important; border: 1px solid var(--hair-soft) !important; border-radius: 12px !important; margin-top: 4px; }
335
+ #p-settings summary, #p-settings > .label-wrap { font-family: var(--font-mono) !important; font-size: 0.68rem !important; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted) !important; }
336
+ #p-settings .p-label { margin-bottom: 4px; }
337
+
338
+ .p-foot {
339
+ display: flex; flex-wrap: wrap; justify-content: space-between; gap: 14px;
340
+ margin-top: 22px; padding-top: 16px; border-top: 1px solid var(--hair-soft);
341
+ font-family: var(--font-mono); font-size: 0.68rem; letter-spacing: 0.05em; line-height: 1.7; color: var(--muted-2);
342
+ }
343
+ .p-foot a { color: var(--muted); text-decoration: none; }
344
+ .p-foot a:hover { color: var(--warm); }
345
+
346
+ @media (max-width: 820px) {
347
+ .gradio-container { padding: 0 14px 20px; }
348
+ #p-shell { flex-direction: column !important; }
349
+ .p-tag { display: none; }
350
+ #p-text textarea { min-height: 220px; }
351
+ #p-rendered, #p-source { min-height: 240px; }
352
+ .p-status { flex-wrap: wrap; }
353
+ .p-detail { margin-left: 0; width: 100%; }
354
+ .p-foot { flex-direction: column; gap: 8px; }
355
+ }
356
+
357
+ @media (prefers-reduced-motion: reduce) {
358
+ *, *::before, *::after { animation: none !important; transition: none !important; }
359
+ }
360
+ """
361
+
362
+ theme = gr.themes.Base(
363
+ primary_hue=gr.themes.colors.orange,
364
+ neutral_hue=gr.themes.colors.gray,
365
+ font=[gr.themes.GoogleFont("Hanken Grotesk"), "system-ui", "sans-serif"],
366
+ font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"],
367
+ )
368
+
369
+ with gr.Blocks(title="PASITA: plain text to Markdown") as demo:
370
+ gr.HTML(HEADER)
371
+ status = gr.HTML(_status("READY"), elem_id="p-status")
372
+
373
+ with gr.Row(elem_id="p-shell"):
374
+ with gr.Column(scale=1, min_width=320, elem_classes=["p-panel"]):
375
+ label_in = gr.HTML(_counter(""))
376
+ text_in = gr.Textbox(
377
+ show_label=False,
378
+ container=False,
379
+ placeholder="Paste OCR output, pasted HTML, meeting notes, a report or an article...",
380
+ lines=18,
381
+ max_lines=36,
382
+ elem_id="p-text",
383
+ )
384
+ with gr.Accordion("SETTINGS", open=False, elem_id="p-settings"):
385
+ candidates = gr.Slider(
386
+ minimum=1, maximum=3, value=3, step=1,
387
+ label="PASITA CANDIDATES",
388
+ info="More samples improve the reranker. 3 is the measured default.",
389
  )
390
+ structured = gr.Checkbox(
391
+ value=True,
392
+ label="DETERMINISTIC CONVERTER FOR HTML AND TABLES",
393
+ info="PASITA does not handle raw HTML or data dumps at this size.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  )
395
+ with gr.Row():
396
+ run = gr.Button("CONVERT TO MARKDOWN", variant="primary", elem_id="p-run")
397
+ clear = gr.Button("CLEAR", elem_id="p-clear")
398
+ with gr.Column(scale=1, min_width=320, elem_classes=["p-panel"]):
399
+ with gr.Tabs(elem_id="p-tabs"):
400
+ with gr.Tab("RENDERED"):
401
+ md_out = gr.Markdown(EMPTY_MD, buttons=["copy"], elem_id="p-rendered")
402
+ with gr.Tab("SOURCE"):
403
+ source = gr.Code(value="", language="markdown", buttons=["copy"], wrap_lines=True, elem_id="p-source")
404
+
405
+ gr.Examples(
406
+ examples=EXAMPLES,
407
+ inputs=[text_in],
408
+ outputs=[md_out, source, status],
409
+ fn=convert,
410
+ cache_examples=True,
411
+ cache_mode="lazy",
412
+ examples_per_page=4,
413
+ label="EXAMPLES",
414
+ elem_id="p-examples",
415
+ )
416
+ gr.HTML(FOOTER)
417
 
418
+ run.click(convert, inputs=[text_in, candidates, structured], outputs=[md_out, source, status], api_name="convert")
419
+ text_in.submit(convert, inputs=[text_in, candidates, structured], outputs=[md_out, source, status], api_name=False)
420
+ text_in.change(_counter, inputs=[text_in], outputs=[label_in], api_name=False)
421
+ clear.click(reset, inputs=None, outputs=[md_out, source, status, label_in], api_name=False)
422
 
423
+ demo.queue()
424
+ demo.launch(theme=theme, css=CSS, mcp_server=True)
pasita.py ADDED
@@ -0,0 +1,1155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Self-contained inference harness for OpceanAI/PASITA text-to-Markdown.
2
+
3
+ No gradio/spaces imports. Works on CPU and bf16 GPU. CLI:
4
+ python3 harness.py --text-file doc.txt
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import math
11
+ import re
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from html.parser import HTMLParser
15
+ from typing import Any
16
+
17
+ import torch
18
+
19
+ MODEL_ID = "OpceanAI/PASITA"
20
+ CONTEXT_LEN = 2048
21
+ DEFAULT_HARD_CAP = 512
22
+
23
+
24
+ def build_prompt(text: str, mode: str = "canonical", instruction: str | None = None) -> str:
25
+ if instruction is not None:
26
+ return instruction + text + "\n\n### Markdown:\n"
27
+ if mode == "canonical":
28
+ return "CONVIERTE A MARKDOWN:\n" + text + "\n\n### Markdown:\n"
29
+ if mode == "english":
30
+ return "CONVERT TO MARKDOWN:\n" + text + "\n\n### Markdown:\n"
31
+ if mode == "spanish_instr":
32
+ return (
33
+ "Transforma el siguiente texto en Markdown, conservando toda la información:\n\n"
34
+ + text
35
+ + "\n\n### Markdown:\n"
36
+ )
37
+ if mode == "fewshot":
38
+ return (
39
+ "CONVIERTE A MARKDOWN:\n\n"
40
+ "### Ejemplo 1\n"
41
+ "Entrada: <b>Aviso</b><p>Entrega en 24 h.</p>\n"
42
+ "Salida:\n"
43
+ "## Aviso\n\n"
44
+ "Entrega en 24 h.\n\n"
45
+ "### Ejemplo 2\n"
46
+ "Entrada: producto: teclado; precio: 49,90 €\n"
47
+ "Salida:\n"
48
+ "- **producto:** teclado\n"
49
+ "- **precio:** 49,90 €\n\n"
50
+ "### Documento\n"
51
+ + text
52
+ + "\n\n### Markdown:\n"
53
+ )
54
+ if mode == "plain":
55
+ return text
56
+ if mode == "md_label":
57
+ return "CONVIERTE A MARKDOWN:\n" + text + "\n\nMarkdown:\n"
58
+ if mode == "sep_only":
59
+ return text + "\n\n### Markdown:\n"
60
+ if mode == "doc_instr_es":
61
+ return "Convierte este documento a Markdown:\n" + text + "\n\n### Markdown:\n"
62
+ if mode == "doc_instr_en":
63
+ return "Convert this document to Markdown:\n" + text + "\n\n### Markdown:\n"
64
+ if mode == "csv_instr":
65
+ return "Convierte este CSV a tabla markdown:\n" + text + "\n\n### Markdown:\n"
66
+ if mode == "html_instr":
67
+ return "Convierte este HTML a Markdown:\n" + text + "\n\n### Markdown:\n"
68
+ if mode == "html_instr_en":
69
+ return "Convert this HTML to Markdown:\n" + text + "\n\n### Markdown:\n"
70
+ if mode == "fewshot2":
71
+ return (
72
+ "CONVIERTE A MARKDOWN:\n\n"
73
+ "Ejemplo:\n"
74
+ "Entrada: Puntos\n"
75
+ "1. Primero.\n"
76
+ "2. Segundo.\n"
77
+ "Salida:\n"
78
+ "## Puntos\n\n"
79
+ "1. Primero.\n"
80
+ "2. Segundo.\n\n"
81
+ "Documento:\n"
82
+ + text
83
+ + "\n\n### Markdown:\n"
84
+ )
85
+ if mode == "respuesta":
86
+ return "CONVIERTE A MARKDOWN:\n" + text + "\n\n### Respuesta:\n"
87
+ if mode == "output":
88
+ return "CONVIERTE A MARKDOWN:\n" + text + "\n\n### Output:\n"
89
+ raise ValueError(f"unknown prompt mode: {mode}")
90
+
91
+
92
+ @dataclass
93
+ class GenerationResult:
94
+ candidates: list[str]
95
+ raw_candidates: list[str]
96
+ prompt: str
97
+ prompt_tokens: int
98
+ max_new_tokens: int
99
+ seconds: float
100
+ strategy: dict[str, Any] = field(default_factory=dict)
101
+
102
+ @property
103
+ def best_index(self) -> int:
104
+ return self.strategy.get("best_index", 0)
105
+
106
+ @property
107
+ def best(self) -> str:
108
+ return self.candidates[self.best_index]
109
+
110
+
111
+ def _model_device(model) -> torch.device:
112
+ try:
113
+ return next(model.parameters()).device
114
+ except StopIteration:
115
+ return torch.device("cpu")
116
+
117
+
118
+ def compute_max_new_tokens(prompt_tokens: int, ratio: float = 1.5, hard_cap: int = DEFAULT_HARD_CAP) -> int:
119
+ budget = CONTEXT_LEN - prompt_tokens - 1
120
+ if budget <= 16:
121
+ return 16
122
+ by_ratio = int(math.ceil(prompt_tokens * ratio))
123
+ return max(16, min(hard_cap, budget, by_ratio))
124
+
125
+
126
+ def _truncate_text_to_tokens(tokenizer, text: str, max_tokens: int) -> str:
127
+ ids = tokenizer.encode(text, add_special_tokens=False)
128
+ if len(ids) <= max_tokens:
129
+ return text
130
+ head = int(max_tokens * 0.6)
131
+ tail = max_tokens - head
132
+ head_text = tokenizer.decode(ids[:head])
133
+ tail_text = tokenizer.decode(ids[-tail:]) if tail > 0 else ""
134
+ return head_text + "\n...\n" + tail_text
135
+
136
+
137
+ def generate_candidates(
138
+ model,
139
+ tokenizer,
140
+ text: str,
141
+ *,
142
+ mode: str = "canonical",
143
+ instruction: str | None = None,
144
+ num_candidates: int = 1,
145
+ num_beams: int = 1,
146
+ do_sample: bool = False,
147
+ temperature: float = 1.0,
148
+ top_p: float = 0.95,
149
+ no_repeat_ngram_size: int = 0,
150
+ repetition_penalty: float = 1.0,
151
+ max_new_tokens: int | str | None = None,
152
+ max_new_ratio: float = 1.5,
153
+ hard_cap: int = DEFAULT_HARD_CAP,
154
+ use_cache: bool = True,
155
+ early_stopping: bool | None = None,
156
+ seed: int = 0,
157
+ device: torch.device | str | None = None,
158
+ **gen_kwargs: Any,
159
+ ) -> GenerationResult:
160
+ tokenizer.padding_side = "left"
161
+ prompt = build_prompt(text, mode, instruction)
162
+ max_new = max_new_tokens
163
+ if max_new is None or max_new == "auto":
164
+ prompt_ids = tokenizer(prompt, add_special_tokens=True)["input_ids"]
165
+ if len(prompt_ids) > CONTEXT_LEN - 64:
166
+ text = _truncate_text_to_tokens(tokenizer, text, CONTEXT_LEN - 96)
167
+ prompt = build_prompt(text, mode, instruction)
168
+ prompt_ids = tokenizer(prompt, add_special_tokens=True)["input_ids"]
169
+ max_new = compute_max_new_tokens(len(prompt_ids), max_new_ratio, hard_cap)
170
+ else:
171
+ max_new = int(max_new)
172
+
173
+ inputs = tokenizer(
174
+ prompt,
175
+ return_tensors="pt",
176
+ add_special_tokens=True,
177
+ truncation=True,
178
+ max_length=CONTEXT_LEN - max_new,
179
+ )
180
+ if device is None:
181
+ device = _model_device(model)
182
+ inputs = {k: v.to(device) for k, v in inputs.items()}
183
+ prompt_tokens = int(inputs["input_ids"].shape[1])
184
+
185
+ if do_sample:
186
+ torch.manual_seed(seed)
187
+ if early_stopping is None:
188
+ early_stopping = num_beams > 1
189
+
190
+ generate_kwargs: dict[str, Any] = dict(
191
+ max_new_tokens=max_new,
192
+ do_sample=do_sample,
193
+ num_beams=max(1, num_beams),
194
+ num_return_sequences=max(1, num_candidates),
195
+ use_cache=use_cache,
196
+ pad_token_id=tokenizer.pad_token_id,
197
+ eos_token_id=tokenizer.eos_token_id,
198
+ early_stopping=early_stopping,
199
+ )
200
+ if no_repeat_ngram_size:
201
+ generate_kwargs["no_repeat_ngram_size"] = no_repeat_ngram_size
202
+ if repetition_penalty and repetition_penalty != 1.0:
203
+ generate_kwargs["repetition_penalty"] = repetition_penalty
204
+ if do_sample:
205
+ generate_kwargs["temperature"] = temperature
206
+ generate_kwargs["top_p"] = top_p
207
+ generate_kwargs.update(gen_kwargs)
208
+
209
+ t0 = time.perf_counter()
210
+ with torch.inference_mode():
211
+ out = model.generate(**inputs, **generate_kwargs)
212
+ seconds = time.perf_counter() - t0
213
+
214
+ new_tokens = out[:, prompt_tokens:]
215
+ raw_candidates = tokenizer.batch_decode(new_tokens, skip_special_tokens=True)
216
+ strategy = {
217
+ "mode": mode,
218
+ "num_candidates": num_candidates,
219
+ "num_beams": max(1, num_beams),
220
+ "do_sample": do_sample,
221
+ "temperature": temperature if do_sample else 0.0,
222
+ "no_repeat_ngram_size": no_repeat_ngram_size,
223
+ "repetition_penalty": repetition_penalty,
224
+ "max_new_tokens": max_new,
225
+ "max_new_ratio": max_new_ratio,
226
+ }
227
+ return GenerationResult(
228
+ candidates=[postprocess(c, text) for c in raw_candidates],
229
+ raw_candidates=raw_candidates,
230
+ prompt=prompt,
231
+ prompt_tokens=prompt_tokens,
232
+ max_new_tokens=max_new,
233
+ seconds=seconds,
234
+ strategy=strategy,
235
+ )
236
+
237
+
238
+ _MARKER_RE = re.compile(
239
+ r"^\s*(?:#{0,6}\s*)?(?:###\s*markdown\s*:?\s*|##\s*answer\s*:?\s*|"
240
+ r"conviert[ea] a markdown\s*:?\s*|convert to markdown\s*:?\s*|"
241
+ r"transforma el siguiente texto[^\n]*|aqu[íi] tienes[^\n]*|"
242
+ r"entrada\s*:|salida\s*:|\[your answer\])\s*$",
243
+ re.IGNORECASE,
244
+ )
245
+
246
+
247
+ def _norm_ws(s: str) -> str:
248
+ return re.sub(r"\s+", " ", s).strip()
249
+
250
+
251
+ def _norm_cmp(s: str) -> str:
252
+ s = s.lower()
253
+ s = re.sub(r"[\u2010-\u2015\u2212]", "-", s)
254
+ s = s.replace("\u00a0", " ")
255
+ return _norm_ws(s)
256
+
257
+
258
+ def _is_marker(line: str) -> bool:
259
+ return bool(_MARKER_RE.match(line))
260
+
261
+
262
+ def _strip_prompt_echo(text: str) -> str:
263
+ lines = text.splitlines()
264
+ while lines and (not lines[0].strip() or _is_marker(lines[0])):
265
+ lines.pop(0)
266
+ while lines and (not lines[-1].strip() or _is_marker(lines[-1])):
267
+ lines.pop()
268
+ for i, line in enumerate(lines):
269
+ if i >= 1 and _is_marker(line) and len(_norm_ws(" ".join(lines[:i]))) >= 30:
270
+ lines = lines[:i]
271
+ break
272
+ return "\n".join(lines).strip()
273
+
274
+
275
+ def _unwrap_code_fence(text: str) -> str:
276
+ lines = text.strip().splitlines()
277
+ if len(lines) < 3:
278
+ return text
279
+ if not lines[0].strip().startswith("```") or lines[-1].strip() != "```":
280
+ return text
281
+ inner = lines[1:-1]
282
+ if any(l.strip().startswith("```") for l in inner):
283
+ return text
284
+ return "\n".join(inner).strip()
285
+
286
+
287
+ def _dedup_leading_h1(text: str) -> str:
288
+ lines = text.strip().splitlines()
289
+ if not lines or not re.match(r"^#\s+\S", lines[0].strip()):
290
+ return text
291
+ head = _norm_cmp(lines[0].lstrip("# "))
292
+ rest = "\n".join(lines[1:])
293
+ if head and head in _norm_cmp(rest):
294
+ return rest.strip()
295
+ return text
296
+
297
+
298
+ def _fix_double_heading(text: str) -> str:
299
+ out = []
300
+ for line in text.splitlines():
301
+ m = re.match(r"^\s*#{1,6}\s+(#{1,6}\s+.+?)\s*$", line)
302
+ if m:
303
+ line = m.group(1)
304
+ out.append(line)
305
+ return "\n".join(out)
306
+
307
+
308
+ def _cut_repeated_tail_paragraphs(text: str, input_text: str) -> str:
309
+ paras = re.split(r"\n\s*\n", text.strip())
310
+ if len(paras) < 2:
311
+ return text
312
+ in_norm = _norm_cmp(input_text) if input_text else ""
313
+ seen: dict[str, int] = {}
314
+ for i, p in enumerate(paras):
315
+ n = _norm_cmp(p)
316
+ if len(n) < 40 or (in_norm and n in in_norm):
317
+ continue
318
+ key = n[:60]
319
+ if key in seen:
320
+ return "\n\n".join(paras[: seen[key]]).strip()
321
+ seen[key] = i
322
+ for i in range(max(1, len(paras) - 3), len(paras)):
323
+ p = paras[i].strip()
324
+ if p.startswith("#") and "\n" not in p:
325
+ body = _norm_cmp(p.lstrip("# "))
326
+ if len(body) >= 6:
327
+ earlier = _norm_cmp("\n\n".join(paras[:i]))
328
+ if body in earlier:
329
+ return "\n\n".join(paras[:i]).strip()
330
+ changed = True
331
+ while changed and len(paras) >= 2:
332
+ changed = False
333
+ last = _norm_cmp(paras[-1])
334
+ if len(last) < 40:
335
+ break
336
+ earlier = [_norm_cmp(p) for p in paras[:-1]]
337
+ if last in earlier or any(last[:60] == e[:60] for e in earlier if len(e) >= 60):
338
+ paras.pop()
339
+ changed = True
340
+ return "\n\n".join(paras).strip()
341
+
342
+
343
+ def _cut_repeated_tail_ngram(text: str, input_text: str, min_n: int = 6) -> str:
344
+ paras = re.split(r"\n\s*\n", text.strip())
345
+ if len(paras) < 3:
346
+ return text
347
+ flat: list[str] = []
348
+ owner: list[int] = []
349
+ for pi, p in enumerate(paras):
350
+ for w in _norm_cmp(p).split():
351
+ flat.append(w)
352
+ owner.append(pi)
353
+ n = len(flat)
354
+ best: tuple[int, int, int] | None = None
355
+ for size in range(min(16, n // 2), min_n - 1, -1):
356
+ seen: dict[tuple[str, ...], int] = {}
357
+ for i in range(n - size + 1):
358
+ gram = tuple(flat[i : i + size])
359
+ if gram in seen:
360
+ j = seen[gram]
361
+ if owner[j] < owner[i] and owner[i] >= len(paras) - 3:
362
+ if best is None or owner[i] < best[0] or (owner[i] == best[0] and owner[j] < best[1]):
363
+ best = (owner[i], owner[j], size)
364
+ else:
365
+ seen[gram] = i
366
+ if best is not None:
367
+ break
368
+ if best is None:
369
+ return text
370
+ s2, f, size = best
371
+ in_norm = _norm_cmp(input_text) if input_text else ""
372
+ for k in range(f + 1, s2):
373
+ nk = _norm_cmp(paras[k])
374
+ if len(nk) >= 30 and (not in_norm or nk not in in_norm):
375
+ return "\n\n".join(paras[:k]).strip()
376
+ if size >= 12:
377
+ return "\n\n".join(paras[:s2]).strip()
378
+ return text
379
+
380
+
381
+ def _norm_line(line: str) -> str:
382
+ return _norm_cmp(re.sub(r"^\s*#{1,6}\s*", "", line))
383
+
384
+
385
+ def _cut_repeated_line_blocks(text: str) -> str:
386
+ lines = text.rstrip().splitlines()
387
+ changed = True
388
+ while changed and len(lines) >= 2:
389
+ changed = False
390
+ n = len(lines)
391
+ for k in range(n // 2, 0, -1):
392
+ if lines[n - k :] != lines[n - 2 * k : n - k]:
393
+ if [_norm_line(l) for l in lines[n - k :]] != [
394
+ _norm_line(l) for l in lines[n - 2 * k : n - k]
395
+ ]:
396
+ continue
397
+ block = [l for l in lines[n - k :] if l.strip()]
398
+ if not block:
399
+ continue
400
+ if k == 1:
401
+ line = lines[n - 1]
402
+ long_enough = len(line.split()) >= 6
403
+ if not long_enough or text.count(line.strip()) < 3:
404
+ continue
405
+ lines = lines[: n - k]
406
+ changed = True
407
+ break
408
+ return "\n".join(lines).rstrip()
409
+
410
+
411
+ def _content_coverage(text: str, input_words: set[str]) -> float:
412
+ if not input_words:
413
+ return 1.0
414
+ words = set(_content_words(text))
415
+ return len(input_words & words) / len(input_words)
416
+
417
+
418
+ def _cut_after_completion(text: str, input_text: str, min_cov: float = 0.85) -> str:
419
+ if not input_text:
420
+ return text
421
+ in_nums = extract_number_keys(input_text)
422
+ if not in_nums:
423
+ return text
424
+ in_words = set(_content_words(input_text))
425
+ lines = text.splitlines()
426
+ seen: set[str] = set()
427
+ for i, line in enumerate(lines):
428
+ seen |= extract_number_keys(line)
429
+ if in_nums <= seen:
430
+ candidate = "\n".join(lines[: i + 1]).rstrip()
431
+ if candidate and _content_coverage(candidate, in_words) >= min_cov:
432
+ return candidate
433
+ break
434
+ return text
435
+
436
+
437
+ def _cut_repeated_char_tail(text: str) -> str:
438
+ t = text.rstrip()
439
+ n = len(t)
440
+ for p in range(24, min(800, n // 2) + 1):
441
+ block = t[n - p :]
442
+ if not block.strip():
443
+ continue
444
+ reps = 1
445
+ while (reps + 1) * p <= n and t[n - (reps + 1) * p : n - reps * p] == block:
446
+ reps += 1
447
+ if reps >= 3:
448
+ return t[: n - reps * p].rstrip()
449
+ if reps >= 2 and p >= 120:
450
+ return t[: n - reps * p].rstrip()
451
+ return t
452
+
453
+
454
+ def _cut_input_echo(text: str, input_text: str) -> str:
455
+ if not input_text:
456
+ return text
457
+ in_lines = [l.strip() for l in input_text.strip().splitlines() if l.strip()]
458
+ out_lines = text.rstrip().splitlines()
459
+ for k in range(min(8, len(out_lines), len(in_lines)), 1, -1):
460
+ tail = [l.strip() for l in out_lines[-k:]]
461
+ if any(not l for l in tail):
462
+ continue
463
+ joined = _norm_ws(" ".join(tail))
464
+ if len(joined.split()) < 8 and k < 2:
465
+ continue
466
+ for i in range(len(in_lines) - k + 1):
467
+ if in_lines[i : i + k] == tail:
468
+ return "\n".join(out_lines[: len(out_lines) - k]).rstrip()
469
+ if out_lines:
470
+ tail = _norm_ws(out_lines[-1])
471
+ earlier = _norm_cmp("\n".join(out_lines[:-1]))
472
+ if len(tail.split()) >= 8 and tail in _norm_cmp(input_text) and _norm_cmp(tail) in earlier:
473
+ return "\n".join(out_lines[:-1]).rstrip()
474
+ return text
475
+
476
+
477
+ def _repair_headings(text: str, input_text: str) -> str:
478
+ if not input_text:
479
+ return text
480
+ import difflib
481
+
482
+ source_lines = [l.strip() for l in input_text.splitlines() if len(l.strip()) >= 8]
483
+ if not source_lines:
484
+ return text
485
+ out_lines = text.splitlines()
486
+ for i, line in enumerate(out_lines):
487
+ m = re.match(r"^(\s*#{1,6}\s+)(.+?)\s*$", line)
488
+ if not m:
489
+ continue
490
+ body = m.group(2).strip()
491
+ if len(body) < 8:
492
+ continue
493
+ best_ratio, best_src = 0.0, None
494
+ for src in source_lines:
495
+ ratio = difflib.SequenceMatcher(None, body.lower(), src.lower()).ratio()
496
+ if ratio > best_ratio:
497
+ best_ratio, best_src = ratio, src
498
+ if best_src and 0.88 <= best_ratio < 1.0:
499
+ out_lines[i] = m.group(1) + best_src
500
+ return "\n".join(out_lines)
501
+
502
+
503
+ def postprocess(raw: str, input_text: str | None = None) -> str:
504
+ text = raw.strip()
505
+ if not text:
506
+ return raw.strip()
507
+ original = text
508
+ text = _unwrap_code_fence(text)
509
+ text = _strip_prompt_echo(text)
510
+ text = _cut_input_echo(text, input_text or "")
511
+ text = _dedup_leading_h1(text)
512
+ text = _repair_headings(text, input_text or "")
513
+ text = _fix_double_heading(text)
514
+ text = _cut_after_completion(text, input_text or "")
515
+ text = _cut_repeated_tail_paragraphs(text, input_text or "")
516
+ text = _cut_repeated_line_blocks(text)
517
+ text = _cut_repeated_tail_ngram(text, input_text or "")
518
+ text = _cut_repeated_char_tail(text)
519
+ text = _strip_prompt_echo(text)
520
+ text = "\n".join(l.rstrip() for l in text.splitlines()).strip()
521
+ return text if text else original
522
+
523
+
524
+ NUM_RE = re.compile(r"\d[\d.,]*%?")
525
+ WORD_RE = re.compile(r"[a-zA-ZáéíóúüñçÁÉÍÓÚÜÑÇ]{3,}")
526
+ LIST_RE = re.compile(r"^\s*(?:[-*•]|\d+[.)])\s+\S", re.M)
527
+ TABLE_ROW_RE = re.compile(r"^\s*\|.*\|\s*$", re.M)
528
+ FENCE_RE = re.compile(r"^\s*(```|~~~)", re.M)
529
+
530
+ STOPWORDS = {
531
+ "the", "and", "for", "are", "but", "not", "you", "your", "with", "that", "this",
532
+ "from", "have", "has", "was", "were", "will", "would", "can", "could", "should",
533
+ "into", "than", "then", "them", "they", "their", "there", "these", "those", "our",
534
+ "its", "it's", "also", "some", "any", "all", "each", "when", "where", "which",
535
+ "who", "what", "how", "why", "does", "did", "been", "being", "about", "over",
536
+ "under", "after", "before", "between", "same", "more", "most", "other", "only",
537
+ "los", "las", "una", "uno", "unos", "unas", "del", "para", "por", "con", "sin",
538
+ "que", "como", "más", "mas", "pero", "sus", "este", "esta", "estos", "estas",
539
+ "eso", "esa", "esos", "esas", "son", "sea", "fue", "han", "hay", "muy", "sin",
540
+ "sobre", "entre", "cuando", "donde", "porque", "también", "tambien", "todos",
541
+ "toda", "todo", "cada", "desde", "hasta", "según", "segun", "solo", "sólo",
542
+ "nos", "les", "nuestro", "nuestra", "vuestro", "vuestra", "del", "al", "se",
543
+ }
544
+
545
+
546
+ def _num_key(token: str) -> str | None:
547
+ t = token.rstrip("%").strip()
548
+ if not t:
549
+ return None
550
+ has_c, has_d = "," in t, "." in t
551
+ if has_c and has_d:
552
+ if t.rfind(",") > t.rfind("."):
553
+ t = t.replace(".", "").replace(",", ".")
554
+ else:
555
+ t = t.replace(",", "")
556
+ elif has_c:
557
+ parts = t.split(",")
558
+ if len(parts[-1]) == 3 and len(parts) == 2 and len(parts[0]) <= 3:
559
+ t = t.replace(",", "")
560
+ else:
561
+ t = t.replace(",", ".")
562
+ elif has_d:
563
+ parts = t.split(".")
564
+ if len(parts) == 2 and len(parts[-1]) == 3 and len(parts[0]) <= 3:
565
+ t = t.replace(".", "")
566
+ try:
567
+ return format(float(t), ".6g")
568
+ except ValueError:
569
+ return t
570
+
571
+
572
+ def extract_number_keys(text: str) -> set[str]:
573
+ keys = set()
574
+ for m in NUM_RE.finditer(text):
575
+ k = _num_key(m.group(0))
576
+ if k is not None and k != "0":
577
+ keys.add(k)
578
+ return keys
579
+
580
+
581
+ def _content_words(text: str) -> list[str]:
582
+ words = [w.lower() for w in WORD_RE.findall(text)]
583
+ return [w for w in words if w not in STOPWORDS]
584
+
585
+
586
+ def _mk():
587
+ from markdown_it import MarkdownIt
588
+
589
+ return MarkdownIt("commonmark").enable("table").enable("strikethrough")
590
+
591
+
592
+ _MD = None
593
+
594
+
595
+ def _count_html_tokens(tokens) -> int:
596
+ total = 0
597
+ for t in tokens:
598
+ if t.type in ("html_block", "html_inline"):
599
+ total += 1
600
+ if getattr(t, "children", None):
601
+ total += _count_html_tokens(t.children)
602
+ return total
603
+
604
+
605
+ def gfm_stats(text: str) -> dict[str, Any]:
606
+ global _MD
607
+ if _MD is None:
608
+ try:
609
+ _MD = _mk()
610
+ except Exception:
611
+ return {"html_tokens": 0, "malformed_tables": 0, "malformed_links": 0, "fences_even": True, "gfm_clean": None}
612
+ try:
613
+ tokens = _MD.parse(text)
614
+ except Exception:
615
+ return {"html_tokens": 0, "malformed_tables": 1, "malformed_links": 0, "fences_even": True, "gfm_clean": 0.0}
616
+ html_tokens = _count_html_tokens(tokens)
617
+ tr_open = sum(1 for t in tokens if t.type == "tr_open")
618
+ pipe_lines = TABLE_ROW_RE.findall(text)
619
+ malformed = 0
620
+ if pipe_lines and tr_open == 0:
621
+ malformed = 1
622
+ link_opens = len(re.findall(r"\]\(", text))
623
+ valid_links = 0
624
+ for link in re.finditer(r"\[[^\]]*\]\(([^)]*)\)", text):
625
+ dest = link.group(1)
626
+ if " " not in dest and "\n" not in dest:
627
+ valid_links += 1
628
+ malformed_links = max(0, link_opens - valid_links)
629
+ if text.count("](") > valid_links and re.search(r"\]\([^)]*$", text, re.M):
630
+ malformed_links = max(malformed_links, 1)
631
+ fences = FENCE_RE.findall(text)
632
+ fences_even = len(fences) % 2 == 0
633
+ clean = 1.0
634
+ if html_tokens:
635
+ clean -= 0.25 * min(4, html_tokens)
636
+ if malformed:
637
+ clean -= 0.5
638
+ if malformed_links:
639
+ clean -= 0.25 * min(4, malformed_links)
640
+ if not fences_even:
641
+ clean -= 0.25
642
+ return {
643
+ "html_tokens": html_tokens,
644
+ "malformed_tables": malformed,
645
+ "malformed_links": malformed_links,
646
+ "fences_even": fences_even,
647
+ "gfm_clean": max(0.0, clean),
648
+ }
649
+
650
+
651
+ def _longest_repeated_ngram(text: str, max_n: int = 64) -> int:
652
+ words = [_norm_cmp(w) for w in text.split()]
653
+ n = len(words)
654
+ for size in range(min(max_n, n // 2), 4, -1):
655
+ seen = set()
656
+ for i in range(n - size + 1):
657
+ gram = tuple(words[i : i + size])
658
+ if gram in seen:
659
+ return size
660
+ seen.add(gram)
661
+ return 0
662
+
663
+
664
+ def degeneracy_stats(text: str) -> dict[str, Any]:
665
+ paras = [_norm_cmp(p) for p in re.split(r"\n\s*\n", text) if len(_norm_cmp(p)) > 80]
666
+ dup_para = len(paras) > len(set(paras))
667
+ longest = _longest_repeated_ngram(text)
668
+ return {"longest_repeated_ngram": longest, "duplicated_paragraph": dup_para}
669
+
670
+
671
+ def list_line_counts(text: str, kind: str = "generic") -> tuple[int, int]:
672
+ in_list = len(LIST_RE.findall(text))
673
+ if kind == "data":
674
+ rows = [l for l in text.splitlines() if "," in l and l.strip()]
675
+ if rows and "year" in rows[0].lower():
676
+ rows = rows[1:]
677
+ in_list += len(rows)
678
+ return in_list, 0
679
+
680
+
681
+ def _preface_ratio(input_text: str, output: str) -> float:
682
+ in_words = set(_content_words(input_text))
683
+ if not in_words:
684
+ return 0.0
685
+ head_words = _content_words("\n".join(output.strip().splitlines()[:3]))[:40]
686
+ if len(head_words) < 8:
687
+ return 0.0
688
+ novel = sum(1 for w in head_words if w not in in_words)
689
+ return novel / len(head_words)
690
+
691
+
692
+ def score_text(input_text: str, output: str, must: list[str] | None = None, kind: str = "generic") -> dict[str, Any]:
693
+ out_norm = _norm_cmp(output)
694
+ in_numbers = extract_number_keys(input_text)
695
+ out_numbers = extract_number_keys(output)
696
+ matched = in_numbers & out_numbers
697
+ number_fidelity = len(matched) / len(in_numbers) if in_numbers else 1.0
698
+
699
+ in_words = _content_words(input_text)
700
+ out_word_set = set(_content_words(output))
701
+ if in_words:
702
+ overlap = sum(1 for w in set(in_words) if w in out_word_set) / len(set(in_words))
703
+ else:
704
+ overlap = 1.0
705
+
706
+ must = must or []
707
+ must_hits = sum(1 for m in must if _norm_cmp(m) in out_norm)
708
+ must_fidelity = must_hits / len(must) if must else None
709
+
710
+ list_in, _ = list_line_counts(input_text, kind)
711
+ list_out = len(LIST_RE.findall(output)) + len(TABLE_ROW_RE.findall(output))
712
+ list_ratio = min(1.0, list_out / list_in) if list_in else 1.0
713
+
714
+ gfm = gfm_stats(output)
715
+ deg = degeneracy_stats(output)
716
+ metrics = {
717
+ "number_fidelity": round(number_fidelity, 4),
718
+ "numbers_in": len(in_numbers),
719
+ "numbers_matched": len(matched),
720
+ "spurious_numbers": len(out_numbers - in_numbers),
721
+ "content_overlap": round(overlap, 4),
722
+ "must_fidelity": None if must_fidelity is None else round(must_fidelity, 4),
723
+ "must_hits": must_hits,
724
+ "must_total": len(must),
725
+ "list_in": list_in,
726
+ "list_out": list_out,
727
+ "list_ratio": round(list_ratio, 4),
728
+ "chars_in": len(input_text),
729
+ "chars_out": len(output),
730
+ "preface_ratio": round(_preface_ratio(input_text, output), 4),
731
+ "degenerate": int(deg["duplicated_paragraph"] or deg["longest_repeated_ngram"] >= 12),
732
+ **gfm,
733
+ **deg,
734
+ }
735
+ metrics["quality"] = round(quality_score(metrics), 4)
736
+ return metrics
737
+
738
+
739
+ def quality_score(metrics: dict[str, Any]) -> float:
740
+ score = 0.0
741
+ score += 0.30 * (metrics.get("must_fidelity") or 0.0)
742
+ score += 0.25 * metrics.get("number_fidelity", 0.0)
743
+ score += 0.15 * metrics.get("content_overlap", 0.0)
744
+ score += 0.10 * metrics.get("list_ratio", 0.0)
745
+ clean = metrics.get("gfm_clean")
746
+ score += 0.20 * (clean if clean is not None else 1.0)
747
+ if metrics.get("degenerate"):
748
+ score -= 0.15
749
+ if metrics.get("html_tokens", 0):
750
+ score -= 0.10
751
+ if metrics.get("malformed_tables", 0):
752
+ score -= 0.10
753
+ if metrics.get("malformed_links", 0):
754
+ score -= 0.05 * min(4, metrics["malformed_links"])
755
+ if metrics.get("preface_ratio", 0.0) > 0.5:
756
+ score -= 0.25
757
+ if metrics.get("spurious_numbers", 0):
758
+ score -= 0.05
759
+ chars_in = metrics.get("chars_in", 0)
760
+ chars_out = metrics.get("chars_out", 0)
761
+ if chars_in and chars_out < 0.2 * chars_in:
762
+ score -= 0.20
763
+ return max(0.0, min(1.5, score))
764
+
765
+
766
+ def rerank_candidates(input_text: str, candidates: list[str], must: list[str] | None = None, kind: str = "generic") -> tuple[int, list[dict]]:
767
+ scored = [(i, score_text(input_text, c, must, kind)) for i, c in enumerate(candidates)]
768
+ best = max(scored, key=lambda pair: pair[1]["quality"])[0] if scored else 0
769
+ return best, [m for _, m in scored]
770
+
771
+
772
+ class _HtmlToMarkdown(HTMLParser):
773
+ def __init__(self):
774
+ super().__init__(convert_charrefs=True)
775
+ self.blocks: list[str] = []
776
+ self.buf: list[str] = []
777
+ self.heading: int | None = None
778
+ self.list_stack: list[str] = []
779
+ self.pending_list: list[str] = []
780
+ self.pending_items: list[str] = []
781
+ self.in_pre = False
782
+ self.href: str | None = None
783
+ self.link_pos: int | None = None
784
+ self.table_rows: list[list[str]] | None = None
785
+ self.row: list[str] | None = None
786
+ self.cell_buf: list[str] | None = None
787
+
788
+ def _text(self) -> str:
789
+ t = "".join(self.buf)
790
+ self.buf = []
791
+ return t
792
+
793
+ def _flush_para(self) -> None:
794
+ t = _norm_ws(self._text())
795
+ if t:
796
+ self.blocks.append(t)
797
+
798
+ def _flush_line(self) -> None:
799
+ t = "".join(self.buf).strip()
800
+ self.buf = []
801
+ if t:
802
+ self.pending_items.append(t)
803
+
804
+ def handle_data(self, data: str) -> None:
805
+ if self.cell_buf is not None:
806
+ self.cell_buf.append(data)
807
+ else:
808
+ self.buf.append(data)
809
+
810
+ def handle_starttag(self, tag: str, attrs) -> None:
811
+ a = dict(attrs)
812
+ if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
813
+ self._flush_para()
814
+ self.heading = int(tag[1])
815
+ elif tag in ("p", "div", "section", "article", "header", "footer", "main", "aside"):
816
+ self._flush_para()
817
+ elif tag == "br":
818
+ self.buf.append("\n")
819
+ elif tag in ("strong", "b"):
820
+ self.buf.append("**")
821
+ elif tag in ("em", "i"):
822
+ self.buf.append("*")
823
+ elif tag == "code" and not self.in_pre:
824
+ self.buf.append("`")
825
+ elif tag == "pre":
826
+ self._flush_para()
827
+ self.in_pre = True
828
+ self.buf.append("```\n")
829
+ elif tag == "a":
830
+ self.href = a.get("href")
831
+ self.link_pos = len(self.buf)
832
+ self.buf.append("")
833
+ elif tag in ("ul", "ol"):
834
+ self._flush_para()
835
+ self.list_stack.append(tag)
836
+ elif tag == "li":
837
+ self._flush_line()
838
+ depth = max(0, len(self.list_stack) - 1)
839
+ ordered = self.list_stack and self.list_stack[-1] == "ol"
840
+ self.buf.append(" " * depth + ("1. " if ordered else "- "))
841
+ elif tag == "table":
842
+ self._flush_para()
843
+ self.table_rows = []
844
+ elif tag == "tr":
845
+ self.row = []
846
+ elif tag in ("td", "th"):
847
+ self.cell_buf = []
848
+
849
+ def handle_endtag(self, tag: str) -> None:
850
+ if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
851
+ t = _norm_ws(self._text())
852
+ if t:
853
+ level = self.heading or 1
854
+ self.blocks.append("#" * level + " " + t)
855
+ self.heading = None
856
+ elif tag in ("p", "div", "section", "article", "header", "footer", "main", "aside"):
857
+ self._flush_para()
858
+ elif tag in ("strong", "b"):
859
+ self.buf.append("**")
860
+ elif tag in ("em", "i"):
861
+ self.buf.append("*")
862
+ elif tag == "code" and not self.in_pre:
863
+ self.buf.append("`")
864
+ elif tag == "pre":
865
+ self.in_pre = False
866
+ t = "".join(self.buf)
867
+ self.buf = []
868
+ self.blocks.append("```\n" + t.strip("\n") + "\n```")
869
+ elif tag == "a":
870
+ if self.link_pos is not None:
871
+ text = "".join(self.buf[self.link_pos :])
872
+ del self.buf[self.link_pos :]
873
+ if self.href:
874
+ self.buf.append(f"[{text}]({self.href})")
875
+ else:
876
+ self.buf.append(text)
877
+ self.link_pos = None
878
+ self.href = None
879
+ elif tag in ("ul", "ol"):
880
+ if self.list_stack:
881
+ self.list_stack.pop()
882
+ if not self.list_stack and self.pending_items:
883
+ self.blocks.append("\n".join(self.pending_items))
884
+ self.pending_items = []
885
+ elif tag == "li":
886
+ self._flush_line()
887
+ elif tag in ("td", "th"):
888
+ if self.row is not None and self.cell_buf is not None:
889
+ self.row.append(_norm_ws("".join(self.cell_buf)))
890
+ self.cell_buf = None
891
+ elif tag == "tr":
892
+ if self.table_rows is not None and self.row is not None:
893
+ self.table_rows.append(self.row)
894
+ self.row = None
895
+ elif tag == "table":
896
+ self._emit_table()
897
+ self.table_rows = None
898
+
899
+ def _emit_table(self) -> None:
900
+ rows = [r for r in (self.table_rows or []) if any(c for c in r)]
901
+ if not rows:
902
+ return
903
+ width = max(len(r) for r in rows)
904
+ header = (rows[0] + [""] * width)[:width]
905
+ lines = ["| " + " | ".join(header) + " |", "| " + " | ".join(["---"] * width) + " |"]
906
+ for r in rows[1:]:
907
+ cells = (r + [""] * width)[:width]
908
+ lines.append("| " + " | ".join(cells) + " |")
909
+ self.blocks.append("\n".join(lines))
910
+
911
+ def finish(self) -> str:
912
+ if self.in_pre:
913
+ self.handle_endtag("pre")
914
+ if self.pending_items:
915
+ self.blocks.append("\n".join(self.pending_items))
916
+ self.pending_items = []
917
+ self._flush_para()
918
+ md = "\n\n".join(b for b in self.blocks if b.strip())
919
+ return re.sub(r"\n{3,}", "\n\n", md).strip()
920
+
921
+
922
+ def looks_like_html(text: str) -> bool:
923
+ return bool(
924
+ re.search(r"</?(?:p|div|h[1-6]|ul|ol|li|table|tr|td|th|br|strong|em|a|span|img|section|article)\b[^>]*>", text, re.I)
925
+ )
926
+
927
+
928
+ def html_to_markdown(text: str) -> str:
929
+ parser = _HtmlToMarkdown()
930
+ parser.feed(text)
931
+ parser.close()
932
+ return parser.finish()
933
+
934
+
935
+ def _split_cells(line: str, delim: str) -> list[str]:
936
+ if delim == ",":
937
+ import csv as _csv
938
+
939
+ return [c.strip() for c in next(_csv.reader([line]))]
940
+ if delim == "\t":
941
+ return [c.strip() for c in line.split("\t")]
942
+ if delim == "|":
943
+ return [c.strip() for c in line.strip().strip("|").split("|")]
944
+ return [c.strip() for c in re.split(r"\s{2,}", line.strip())]
945
+
946
+
947
+ def _find_tabular_block(text: str):
948
+ lines = [l for l in text.splitlines() if l.strip()]
949
+ if len(lines) < 3:
950
+ return None
951
+ for delim in (",", "\t", "|", " "):
952
+ for start in range(0, min(2, len(lines))):
953
+ seg = lines[start:]
954
+ if len(seg) < 3:
955
+ continue
956
+ counts = [len(_split_cells(l, delim)) for l in seg]
957
+ if min(counts) >= 2 and max(counts) == min(counts):
958
+ return delim, lines[:start], seg
959
+ return None
960
+
961
+
962
+ def delimited_to_markdown_table(text: str) -> str | None:
963
+ found = _find_tabular_block(text)
964
+ if not found:
965
+ return None
966
+ delim, pre, seg = found
967
+ rows = [_split_cells(l, delim) for l in seg]
968
+ width = len(rows[0])
969
+ if width < 2 or any(len(r) != width for r in rows):
970
+ return None
971
+ def esc(cells: list[str]) -> list[str]:
972
+ return [c.replace("|", "\\|") for c in cells]
973
+ out: list[str] = []
974
+ if pre:
975
+ out.append("\n".join(pre).strip())
976
+ out.append("| " + " | ".join(esc(rows[0])) + " |")
977
+ out.append("| " + " | ".join(["---"] * width) + " |")
978
+ for r in rows[1:]:
979
+ out.append("| " + " | ".join(esc(r)) + " |")
980
+ return "\n".join(out)
981
+
982
+
983
+ def infer_kind(text: str) -> str:
984
+ if looks_like_html(text):
985
+ return "html"
986
+ if _find_tabular_block(text):
987
+ return "data"
988
+ return "generic"
989
+
990
+
991
+ def structured_candidates(text: str) -> list[str]:
992
+ cands: list[str] = []
993
+ if looks_like_html(text):
994
+ try:
995
+ cands.append(html_to_markdown(text))
996
+ except Exception:
997
+ pass
998
+ table = delimited_to_markdown_table(text)
999
+ if table:
1000
+ cands.append(table)
1001
+ return cands
1002
+
1003
+
1004
+ def convert_to_markdown(
1005
+ model,
1006
+ tokenizer,
1007
+ text: str,
1008
+ *,
1009
+ must: list[str] | None = None,
1010
+ kind: str = "generic",
1011
+ structured_fallback: bool = True,
1012
+ skip_model_for_structured: bool = False,
1013
+ **gen_kwargs: Any,
1014
+ ) -> dict[str, Any]:
1015
+ if kind == "generic":
1016
+ kind = infer_kind(text)
1017
+ if skip_model_for_structured and structured_fallback and kind in ("html", "data"):
1018
+ candidates = [postprocess(c, text) for c in structured_candidates(text)]
1019
+ if not candidates:
1020
+ candidates = [postprocess(text, text)]
1021
+ best, metrics = rerank_candidates(text, candidates, must, kind)
1022
+ return {
1023
+ "markdown": candidates[best],
1024
+ "candidate_index": best,
1025
+ "fallback_used": True,
1026
+ "metrics": metrics[best],
1027
+ "candidate_metrics": metrics,
1028
+ "candidates": candidates,
1029
+ "raw_candidates": [],
1030
+ "prompt_tokens": 0,
1031
+ "max_new_tokens": 0,
1032
+ "seconds": 0.0,
1033
+ "strategy": {"mode": "structured", "best_index": best, "structured_fallback": True},
1034
+ }
1035
+ result = generate_candidates(model, tokenizer, text, **gen_kwargs)
1036
+ candidates = list(result.candidates)
1037
+ fallback_start = len(candidates)
1038
+ if structured_fallback:
1039
+ for extra in structured_candidates(text):
1040
+ candidates.append(postprocess(extra, text))
1041
+ best, metrics = rerank_candidates(text, candidates, must, kind)
1042
+ chosen = candidates[best]
1043
+ if not chosen.strip():
1044
+ chosen = text.strip()
1045
+ metrics[best]["quality"] = 0.0
1046
+ result.strategy["best_index"] = best
1047
+ result.strategy["structured_fallback"] = structured_fallback
1048
+ return {
1049
+ "markdown": chosen,
1050
+ "candidate_index": best,
1051
+ "fallback_used": best >= fallback_start,
1052
+ "metrics": metrics[best],
1053
+ "candidate_metrics": metrics,
1054
+ "candidates": candidates,
1055
+ "raw_candidates": result.raw_candidates,
1056
+ "prompt_tokens": result.prompt_tokens,
1057
+ "max_new_tokens": result.max_new_tokens,
1058
+ "seconds": result.seconds,
1059
+ "strategy": result.strategy,
1060
+ }
1061
+
1062
+
1063
+ def load_model(model_id: str = MODEL_ID, device: str | None = None, local_files_only: bool = True):
1064
+ from transformers import AutoModelForCausalLM, AutoTokenizer
1065
+
1066
+ tokenizer = AutoTokenizer.from_pretrained(model_id, local_files_only=local_files_only)
1067
+ model = AutoModelForCausalLM.from_pretrained(model_id, dtype="auto", local_files_only=local_files_only)
1068
+ model.eval()
1069
+ if device:
1070
+ model.to(device)
1071
+ return model, tokenizer
1072
+
1073
+
1074
+ def main(argv: list[str] | None = None) -> None:
1075
+ parser = argparse.ArgumentParser(description="PASITA text-to-Markdown CLI")
1076
+ parser.add_argument("--text-file", required=True)
1077
+ parser.add_argument("--model", default=MODEL_ID)
1078
+ parser.add_argument(
1079
+ "--mode",
1080
+ default="canonical",
1081
+ choices=[
1082
+ "canonical",
1083
+ "english",
1084
+ "spanish_instr",
1085
+ "fewshot",
1086
+ "fewshot2",
1087
+ "plain",
1088
+ "md_label",
1089
+ "respuesta",
1090
+ "output",
1091
+ "sep_only",
1092
+ "doc_instr_es",
1093
+ "doc_instr_en",
1094
+ "csv_instr",
1095
+ "html_instr",
1096
+ "html_instr_en",
1097
+ ],
1098
+ )
1099
+ parser.add_argument("--instruction", default=None)
1100
+ parser.add_argument("--candidates", type=int, default=3)
1101
+ parser.add_argument("--beams", type=int, default=1)
1102
+ parser.add_argument("--no-repeat-ngram", type=int, default=0)
1103
+ parser.add_argument("--repetition-penalty", type=float, default=1.0)
1104
+ parser.add_argument("--temperature", type=float, default=0.3)
1105
+ parser.add_argument("--min-new", type=int, default=0)
1106
+ parser.add_argument("--max-new", default="auto")
1107
+ parser.add_argument("--max-new-ratio", type=float, default=1.5)
1108
+ parser.add_argument("--hard-cap", type=int, default=DEFAULT_HARD_CAP)
1109
+ parser.add_argument("--seed", type=int, default=0)
1110
+ parser.add_argument("--device", default=None)
1111
+ parser.add_argument("--no-structured-fallback", action="store_true")
1112
+ parser.add_argument("--show-candidates", action="store_true")
1113
+ args = parser.parse_args(argv)
1114
+
1115
+ with open(args.text_file, encoding="utf-8") as fh:
1116
+ text = fh.read()
1117
+
1118
+ model, tokenizer = load_model(args.model, args.device)
1119
+ max_new: Any = args.max_new
1120
+ if args.max_new != "auto":
1121
+ max_new = int(args.max_new)
1122
+ gen_kwargs: dict[str, Any] = dict(
1123
+ mode=args.mode,
1124
+ instruction=args.instruction,
1125
+ num_candidates=max(1, args.candidates),
1126
+ num_beams=max(1, args.beams),
1127
+ do_sample=args.temperature > 0,
1128
+ temperature=args.temperature if args.temperature > 0 else 1.0,
1129
+ no_repeat_ngram_size=args.no_repeat_ngram,
1130
+ repetition_penalty=args.repetition_penalty,
1131
+ max_new_tokens=max_new,
1132
+ max_new_ratio=args.max_new_ratio,
1133
+ hard_cap=args.hard_cap,
1134
+ seed=args.seed,
1135
+ )
1136
+ if args.min_new > 0:
1137
+ gen_kwargs["min_new_tokens"] = args.min_new
1138
+ result = convert_to_markdown(
1139
+ model,
1140
+ tokenizer,
1141
+ text,
1142
+ structured_fallback=not args.no_structured_fallback,
1143
+ **gen_kwargs,
1144
+ )
1145
+ if args.show_candidates:
1146
+ for i, (cand, met) in enumerate(zip(result["candidates"], result["candidate_metrics"])):
1147
+ marker = " *" if i == result["candidate_index"] else ""
1148
+ print(f"----- candidate {i}{marker} quality={met['quality']} -----")
1149
+ print(cand)
1150
+ print()
1151
+ print(result["markdown"])
1152
+
1153
+
1154
+ if __name__ == "__main__":
1155
+ main()
requirements.txt CHANGED
@@ -1,2 +1,3 @@
1
  transformers
2
  mcp>=1.21.0,<2.0.0
 
 
1
  transformers
2
  mcp>=1.21.0,<2.0.0
3
+ markdown-it-py