OpceanAI commited on
Commit
ce3d20c
·
verified ·
1 Parent(s): 17072ab

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +269 -18
app.py CHANGED
@@ -1,19 +1,36 @@
1
  import html
 
 
2
  import time
 
3
 
 
 
 
4
  import spaces
5
  import torch
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._"
@@ -48,6 +65,25 @@ def _estimate_duration(text, num_candidates=3, structured_fallback=True, *args,
48
  return min(30, max(12, 8 + len(text or "") // 200 + 4 * (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.
@@ -68,27 +104,14 @@ def convert(text: str, num_candidates: int = 3, structured_fallback: bool = True
68
  if not text:
69
  return EMPTY_MD, "", _status("READY / PASTE A DOCUMENT TO CONVERT")
70
 
71
- started = time.perf_counter()
72
  try:
73
- result = convert_to_markdown(
74
- model,
75
- tokenizer,
76
- text,
77
- num_candidates=max(1, min(3, int(num_candidates))),
78
- do_sample=True,
79
- temperature=0.3,
80
- top_p=0.95,
81
- seed=0,
82
- use_cache=True,
83
- structured_fallback=bool(structured_fallback),
84
- skip_model_for_structured=True,
85
- )
86
  except Exception as exc:
87
  message = html.escape(f"{type(exc).__name__}: {exc}")
88
  return (
89
  f"> Conversion failed.\n>\n> `{message}`",
90
  "",
91
- _status(f"ERROR / {type(exc).__name__}", "error", f"{time.perf_counter() - started:.1f}S"),
92
  )
93
 
94
  metrics = result["metrics"]
@@ -100,15 +123,243 @@ def convert(text: str, num_candidates: int = 3, structured_fallback: bool = True
100
  "passthrough": "SOURCE PASSTHROUGH",
101
  }
102
  source_label = routes.get(result.get("route"), "PASITA + RERANK")
103
- taken = result["seconds"] if result["seconds"] else time.perf_counter() - started
104
  detail = f"{source_label} / {numbers} / {overlap} / {taken:.1f}S"
105
  return result["markdown"], result["markdown"], _status("DONE", "ready", detail)
106
 
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  def reset() -> tuple[str, str, str, str]:
109
  return EMPTY_MD, "", _status("READY"), _counter("")
110
 
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  BUSY_JS = """(text, num_candidates, structured_fallback, md, source, status) => {
113
  const n = Number(num_candidates) || 1;
114
  const label = n > 1 ? 'PASITA ' + n + ' SAMPLES / RERANK' : 'PASITA SAMPLE / RERANK';
@@ -469,4 +720,4 @@ with gr.Blocks(title="PASITA: plain text to Markdown") as demo:
469
  clear.click(reset, inputs=None, outputs=[md_out, source, status, label_in], api_visibility="undocumented")
470
 
471
  demo.queue()
472
- demo.launch(theme=theme, css=CSS, mcp_server=True)
 
1
  import html
2
+ import json
3
+ import os
4
  import time
5
+ import uuid
6
 
7
+ os.environ.setdefault("GRADIO_SSR_MODE", "false")
8
+
9
+ import fastapi
10
  import spaces
11
  import torch
12
  import gradio as gr
13
+ from fastapi.responses import JSONResponse, StreamingResponse
14
+ from gradio import Server
15
+ from gradio.context import LocalContext
16
+ from pydantic import BaseModel
17
  from transformers import AutoModelForCausalLM, AutoTokenizer
18
 
19
+ try:
20
+ from gradio.route_utils import Request as GradioRequest
21
+ except ImportError:
22
+ GradioRequest = None
23
+
24
  from pasita import infer_kind, convert_to_markdown
25
 
26
  MODEL_ID = "OpceanAI/PASITA"
27
  MODEL_CARD = "https://huggingface.co/OpceanAI/PASITA"
28
+ MAX_INPUT_CHARS = 60_000
29
 
30
  tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
31
  tokenizer.padding_side = "left"
32
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
33
+ model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype="auto").to(DEVICE)
34
  model.eval()
35
 
36
  EMPTY_MD = "_The converted Markdown appears here. Paste a document on the left and press Convert._"
 
65
  return min(30, max(12, 8 + len(text or "") // 200 + 4 * (int(num_candidates) - 1)))
66
 
67
 
68
+ def _run_harness(text: str, num_candidates: int, structured_fallback: bool) -> dict:
69
+ started = time.perf_counter()
70
+ result = convert_to_markdown(
71
+ model,
72
+ tokenizer,
73
+ text,
74
+ num_candidates=max(1, min(3, int(num_candidates))),
75
+ do_sample=True,
76
+ temperature=0.3,
77
+ top_p=0.95,
78
+ seed=0,
79
+ use_cache=True,
80
+ structured_fallback=bool(structured_fallback),
81
+ skip_model_for_structured=True,
82
+ )
83
+ result["elapsed"] = time.perf_counter() - started
84
+ return result
85
+
86
+
87
  @spaces.GPU(duration=_estimate_duration)
88
  def convert(text: str, num_candidates: int = 3, structured_fallback: bool = True) -> tuple[str, str, str]:
89
  """Convert a plain-text document into faithful GitHub-Flavored Markdown.
 
104
  if not text:
105
  return EMPTY_MD, "", _status("READY / PASTE A DOCUMENT TO CONVERT")
106
 
 
107
  try:
108
+ result = _run_harness(text, num_candidates, structured_fallback)
 
 
 
 
 
 
 
 
 
 
 
 
109
  except Exception as exc:
110
  message = html.escape(f"{type(exc).__name__}: {exc}")
111
  return (
112
  f"> Conversion failed.\n>\n> `{message}`",
113
  "",
114
+ _status(f"ERROR / {type(exc).__name__}", "error"),
115
  )
116
 
117
  metrics = result["metrics"]
 
123
  "passthrough": "SOURCE PASSTHROUGH",
124
  }
125
  source_label = routes.get(result.get("route"), "PASITA + RERANK")
126
+ taken = result["seconds"] if result["seconds"] else result["elapsed"]
127
  detail = f"{source_label} / {numbers} / {overlap} / {taken:.1f}S"
128
  return result["markdown"], result["markdown"], _status("DONE", "ready", detail)
129
 
130
 
131
+ @spaces.GPU(duration=_estimate_duration)
132
+ def gpu_convert(text: str, num_candidates: int = 3, structured_fallback: bool = True) -> dict:
133
+ """Convert a document and return the full conversion result.
134
+
135
+ Same pipeline as the UI conversion. Used by the /v1 OpenAI-style API routes.
136
+
137
+ Args:
138
+ text: The document to convert.
139
+ num_candidates: How many PASITA samples to generate and rerank, from 1 to 3.
140
+ structured_fallback: Use the deterministic converter for HTML and delimited data.
141
+ Returns:
142
+ The full result dict: markdown, route, metrics, timing and candidate info.
143
+ """
144
+ return _run_harness(text, num_candidates, structured_fallback)
145
+
146
+
147
+ COUNTER_JS = """(text) => {
148
+ const t = (text || '').trim();
149
+ const words = t ? t.split(/\\s+/).length : 0;
150
+ const chars = (text || '').length;
151
+ return '<span class="p-label">PLAIN TEXT</span><span class="p-count">' + chars.toLocaleString() + ' CHARS / ' + words.toLocaleString() + ' WORDS</span>';
152
+ }"""
153
+
154
+ API_DESCRIPTION = """
155
+ OpenAI-style REST API for [PASITA](https://huggingface.co/OpceanAI/PASITA), an 88M
156
+ from-scratch model that turns plain text into faithful Markdown. No API key is required.
157
+ Works with the OpenAI SDK by pointing `base_url` at `/v1`. The Gradio API and the MCP
158
+ server stay available at `?view=api`.
159
+ """.strip()
160
+
161
+
162
+ def _api_error(message: str, error_type: str = "invalid_request_error", status: int = 400, code: str | None = None) -> JSONResponse:
163
+ return JSONResponse(
164
+ status_code=status,
165
+ content={"error": {"message": message, "type": error_type, "param": None, "code": code}},
166
+ )
167
+
168
+
169
+ def _with_request_context(request: fastapi.Request, text: str, candidates: int, fallback: bool) -> dict:
170
+ if GradioRequest is not None:
171
+ gr_request = GradioRequest(request=request, session_hash="api")
172
+ token = LocalContext.request.set(gr_request)
173
+ try:
174
+ return gpu_convert(text, candidates, fallback)
175
+ finally:
176
+ LocalContext.request.reset(token)
177
+ return gpu_convert(text, candidates, fallback)
178
+
179
+
180
+ def _token_counts(text: str, markdown: str) -> tuple[int, int]:
181
+ prompt_tokens = len(tokenizer(text, add_special_tokens=True)["input_ids"])
182
+ completion_tokens = len(tokenizer(markdown, add_special_tokens=False)["input_ids"])
183
+ return prompt_tokens, completion_tokens
184
+
185
+
186
+ class ChatMessage(BaseModel):
187
+ role: str = "user"
188
+ content: str | list | None = None
189
+
190
+
191
+ class ChatCompletionRequest(BaseModel):
192
+ model: str | None = "pasita-v1"
193
+ messages: list[ChatMessage]
194
+ stream: bool = False
195
+ candidates: int = 3
196
+ structured_fallback: bool = True
197
+
198
+
199
+ class ConvertRequest(BaseModel):
200
+ text: str
201
+ candidates: int = 3
202
+ structured_fallback: bool = True
203
+
204
+
205
+ server = Server(title="PASITA API", version="1.0.0", description=API_DESCRIPTION)
206
+
207
+
208
+ @server.exception_handler(fastapi.exceptions.RequestValidationError)
209
+ async def validation_error_handler(request: fastapi.Request, exc: fastapi.exceptions.RequestValidationError):
210
+ issues = "; ".join(f"{'.'.join(str(l) for l in e.get('loc', []))}: {e.get('msg', '')}" for e in exc.errors()[:3])
211
+ return _api_error(f"Invalid request body: {issues}", status=400)
212
+
213
+
214
+ @server.get("/v1/models")
215
+ def list_models() -> dict:
216
+ return {
217
+ "object": "list",
218
+ "data": [{"id": "pasita-v1", "object": "model", "created": 1760000000, "owned_by": "OpceanAI"}],
219
+ }
220
+
221
+
222
+ @server.get("/v1/health")
223
+ def health() -> dict:
224
+ return {"status": "ok", "model": "pasita-v1"}
225
+
226
+
227
+ def _validate(body: ChatCompletionRequest | ConvertRequest, text: str | None) -> JSONResponse | None:
228
+ if text is None or not text.strip():
229
+ return _api_error("No input text was provided.", code="empty_input")
230
+ if len(text) > MAX_INPUT_CHARS:
231
+ return _api_error(
232
+ f"The document is too long ({len(text):,} chars). The limit is {MAX_INPUT_CHARS:,} characters.",
233
+ code="input_too_long",
234
+ )
235
+ candidates = getattr(body, "candidates", 3)
236
+ if not 1 <= int(candidates) <= 3:
237
+ return _api_error("candidates must be between 1 and 3.", code="invalid_candidates")
238
+ return None
239
+
240
+
241
+ def _result_payload(result: dict, text: str, started: float) -> dict:
242
+ markdown = result["markdown"]
243
+ prompt_tokens, completion_tokens = _token_counts(text, markdown)
244
+ return {
245
+ "markdown": markdown,
246
+ "route": result.get("route", "model"),
247
+ "metrics": result["metrics"],
248
+ "candidates": len(result.get("candidates", [])),
249
+ "seconds": round(result["seconds"] or result["elapsed"], 2),
250
+ "usage": {
251
+ "prompt_tokens": prompt_tokens,
252
+ "completion_tokens": completion_tokens,
253
+ "total_tokens": prompt_tokens + completion_tokens,
254
+ },
255
+ "elapsed": round(time.perf_counter() - started, 2),
256
+ }
257
+
258
+
259
+ @server.post("/v1/convert")
260
+ def v1_convert(body: ConvertRequest, request: fastapi.Request) -> dict | JSONResponse:
261
+ error = _validate(body, body.text)
262
+ if error is not None:
263
+ return error
264
+ started = time.perf_counter()
265
+ try:
266
+ result = _with_request_context(request, body.text.strip(), int(body.candidates), bool(body.structured_fallback))
267
+ except Exception as exc:
268
+ return _api_error(f"{type(exc).__name__}: {exc}", "server_error", status=500)
269
+ payload = _result_payload(result, body.text.strip(), started)
270
+ payload.update({"id": f"conv-{uuid.uuid4().hex[:24]}", "object": "pasita.conversion", "created": int(time.time()), "model": "pasita-v1"})
271
+ return payload
272
+
273
+
274
+ def _extract_user_text(messages: list[ChatMessage]) -> str | None:
275
+ for message in reversed(messages):
276
+ if message.role != "user":
277
+ continue
278
+ content = message.content
279
+ if isinstance(content, str):
280
+ return content
281
+ if isinstance(content, list):
282
+ parts = []
283
+ for part in content:
284
+ if isinstance(part, dict) and part.get("type") == "text":
285
+ parts.append(str(part.get("text", "")))
286
+ elif isinstance(part, str):
287
+ parts.append(part)
288
+ return "\n".join(p for p in parts if p)
289
+ return None
290
+ return None
291
+
292
+
293
  def reset() -> tuple[str, str, str, str]:
294
  return EMPTY_MD, "", _status("READY"), _counter("")
295
 
296
 
297
+ @server.post("/v1/chat/completions")
298
+ def chat_completions(body: ChatCompletionRequest, request: fastapi.Request) -> dict | JSONResponse | StreamingResponse:
299
+ text = _extract_user_text(body.messages)
300
+ error = _validate(body, text)
301
+ if error is not None:
302
+ return error
303
+
304
+ started = time.perf_counter()
305
+ try:
306
+ result = _with_request_context(request, text.strip(), int(body.candidates), bool(body.structured_fallback))
307
+ except Exception as exc:
308
+ return _api_error(f"{type(exc).__name__}: {exc}", "server_error", status=500)
309
+
310
+ payload = _result_payload(result, text.strip(), started)
311
+ completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
312
+ created = int(time.time())
313
+ markdown = payload["markdown"]
314
+
315
+ if body.stream:
316
+ def sse():
317
+ def chunk(delta, finish=None, usage=None):
318
+ data = {
319
+ "id": completion_id,
320
+ "object": "chat.completion.chunk",
321
+ "created": created,
322
+ "model": "pasita-v1",
323
+ "choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
324
+ }
325
+ if usage is not None:
326
+ data["usage"] = usage
327
+ return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
328
+
329
+ yield chunk({"role": "assistant", "content": ""})
330
+ yield chunk({"content": markdown})
331
+ yield chunk({}, finish="stop", usage=payload["usage"])
332
+ yield "data: [DONE]\n\n"
333
+
334
+ return StreamingResponse(
335
+ sse(),
336
+ media_type="text/event-stream",
337
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
338
+ )
339
+
340
+ return {
341
+ "id": completion_id,
342
+ "object": "chat.completion",
343
+ "created": created,
344
+ "model": "pasita-v1",
345
+ "choices": [
346
+ {
347
+ "index": 0,
348
+ "message": {"role": "assistant", "content": markdown},
349
+ "logprobs": None,
350
+ "finish_reason": "stop",
351
+ }
352
+ ],
353
+ "usage": payload["usage"],
354
+ "pasita": {
355
+ "route": payload["route"],
356
+ "metrics": payload["metrics"],
357
+ "candidates": payload["candidates"],
358
+ "seconds": payload["seconds"],
359
+ },
360
+ }
361
+
362
+
363
  BUSY_JS = """(text, num_candidates, structured_fallback, md, source, status) => {
364
  const n = Number(num_candidates) || 1;
365
  const label = n > 1 ? 'PASITA ' + n + ' SAMPLES / RERANK' : 'PASITA SAMPLE / RERANK';
 
720
  clear.click(reset, inputs=None, outputs=[md_out, source, status, label_in], api_visibility="undocumented")
721
 
722
  demo.queue()
723
+ demo.launch(_app=server, theme=theme, css=CSS, mcp_server=True)