File size: 21,328 Bytes
b9d34d8
 
 
 
 
 
 
 
 
 
 
 
1f46341
b9d34d8
 
 
 
74fe989
b9d34d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74fe989
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b9d34d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4306e95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f46341
 
 
 
 
0992cee
 
 
 
1f46341
4306e95
 
b9d34d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f46341
 
 
 
 
 
 
 
b9d34d8
 
 
 
 
 
 
 
 
 
74fe989
 
 
 
 
 
b9d34d8
74fe989
 
b9d34d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0992cee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97ff337
 
 
0992cee
 
 
 
 
 
 
 
 
 
 
97ff337
 
 
 
 
 
 
 
0992cee
 
 
 
 
 
 
97ff337
 
 
0992cee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
"""PDF text extraction app β€” FastAPI backend.

Run locally with:
    uvicorn app:app --reload
(serves on http://127.0.0.1:8000)

Local and CPU-only by default. An online OCR path (Google Gemini) is optional
and off unless you supply your own API key.
"""

import io
import json
import os
import asyncio
from pathlib import Path

from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Body
from fastapi.responses import FileResponse, StreamingResponse, JSONResponse
from fastapi.staticfiles import StaticFiles

from pipeline.jobs import manager
from pipeline.ocr_engine import engine
from pipeline.export_docx import build_docx
from pipeline.analyze import suggest_settings

BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
INDEX_HTML = STATIC_DIR / "index.html"

app = FastAPI(title="Local PDF Text Extractor")

# Serve static assets (CSS/JS) locally β€” no external CDN/network dependency.
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")

# Reject oversized request bodies early β€” before FastAPI buffers/spools the
# upload β€” so a huge (or many concurrent) POST can't exhaust memory. Generous
# enough to never affect normal local use (the primary, trusted single-user
# case); it mainly caps the DoS surface of the public demo Space, where body
# size is attacker-controlled. Checked by Content-Length, so an honest client
# (browser / curl) is rejected before the body is read.
MAX_UPLOAD_BYTES = 200 * 1024 * 1024  # 200 MB


@app.middleware("http")
async def _limit_request_body(request, call_next):
    cl = request.headers.get("content-length")
    if cl:
        try:
            oversized = int(cl) > MAX_UPLOAD_BYTES
        except ValueError:
            oversized = False
        if oversized:
            return JSONResponse(
                {"detail": "Request body exceeds the {} MB limit.".format(
                    MAX_UPLOAD_BYTES // (1024 * 1024))},
                status_code=413,
            )
    return await call_next(request)

# Hold strong references to fire-and-forget background tasks: asyncio keeps only
# a weak reference to a Task, so a bare create_task() result can in principle be
# GC'd mid-flight ("Task was destroyed but it is pending"). Keeping them here and
# discarding on completion makes the lifetime explicit.
_BG_TASKS: set = set()


def _spawn(coro) -> None:
    task = asyncio.create_task(coro)
    _BG_TASKS.add(task)
    task.add_done_callback(_BG_TASKS.discard)


def _parse_bool(value: str) -> bool:
    """Parse a form string into a bool. true/1/yes (case-insensitive) -> True."""
    return str(value).strip().lower() in ("true", "1", "yes")


@app.on_event("startup")
async def _startup() -> None:
    # Preload the English PaddleOCR model at startup so the first request is fast
    # (first ever run still downloads model weights from disk cache). This is
    # best-effort: a failed/slow first-run download must NEVER abort startup β€”
    # text-layer extraction and the online Gemini path don't need PaddleOCR at
    # all. Run it in the OCR worker thread so a slow download can't block the
    # event loop, and swallow any error (OCR lazy-loads on first real use).
    from pipeline.jobs import _EXECUTOR

    async def _safe_warmup() -> None:
        try:
            loop = asyncio.get_running_loop()
            await loop.run_in_executor(_EXECUTOR, engine.warmup, "en")
        except Exception:
            pass

    _spawn(_safe_warmup())


@app.get("/")
async def index():
    """Serve the single-page front-end."""
    return FileResponse(str(INDEX_HTML))


# Cache the (deterministic) optional-feature probe so torch/transformers are
# imported at most once.
_CAPABILITIES: dict = {}


@app.get("/api/capabilities")
async def capabilities():
    """Report which optional features are usable in THIS deployment.

    Local handwriting (TrOCR) needs torch + transformers, which the lean hosted
    build omits. The front-end reads this to disable the handwriting toggle
    gracefully and point users to online OCR, instead of letting a page fail
    mid-extraction with a missing-dependency error.
    """
    if "handwriting" not in _CAPABILITIES:
        from pipeline.jobs import _EXECUTOR
        from pipeline.handwriting import is_available

        loop = asyncio.get_running_loop()
        try:
            _CAPABILITIES["handwriting"] = await loop.run_in_executor(
                _EXECUTOR, is_available
            )
        except Exception:
            _CAPABILITIES["handwriting"] = False
    return {
        "handwriting": bool(_CAPABILITIES["handwriting"]),
        # True when this deployment provides a shared Gemini key (a Space secret),
        # so the front-end can offer online OCR without the visitor's own key.
        "online_demo": bool(os.environ.get("DEMO_GEMINI_KEY", "").strip()),
        # Knowledge-graph search is always supported by the backend (stdlib +
        # numpy, no torch/networkx). It needs a Gemini key at build time β€” the
        # user's own key or, where present, the demo key (online_demo above).
        "knowledge_graph": True,
    }


@app.post("/api/upload")
async def upload(
    file: UploadFile = File(...),
    mode: str = Form("max"),
    lang: str = Form("en"),
    preprocess: str = Form("true"),
    binarize: str = Form("false"),
    handwriting: str = Form("false"),
    online: str = Form("false"),
    online_key: str = Form(""),
    online_model: str = Form(""),
    force_ocr: str = Form("false"),
    remove_headers: str = Form("true"),
):
    """Accept a PDF upload, create a job, and kick off background processing."""
    pre = _parse_bool(preprocess)
    binar = _parse_bool(binarize)
    handw = _parse_bool(handwriting)
    onl = _parse_bool(online)
    okey = (online_key or "").strip()
    omodel = (online_model or "").strip()
    force = _parse_bool(force_ocr)
    rm_headers = _parse_bool(remove_headers)

    # Cheap validations BEFORE buffering the whole upload into memory, so a
    # request that will be rejected anyway doesn't pay a full file read.
    #
    # A deployment may provide a shared demo key via the DEMO_GEMINI_KEY env var
    # (set as a Space secret, never committed to the repo). Fall back to it when
    # the visitor turns on online OCR without supplying their own key, so the
    # hosted demo works out of the box. The user's own key always takes
    # precedence; this only fills in a blank.
    if onl and not okey:
        okey = os.environ.get("DEMO_GEMINI_KEY", "").strip()
    if onl and not okey:
        raise HTTPException(
            status_code=400,
            detail=(
                "Online OCR needs a Gemini API key. Enter your free key from "
                "aistudio.google.com, or turn Online OCR off."
            ),
        )

    if handw:
        # is_available() imports torch/transformers on first call (multi-second
        # on the CPU build). Offload it to the OCR worker thread β€” like
        # capabilities() does β€” so it never stalls the event loop (which would
        # freeze other requests' SSE progress). create_job below already awaits
        # the same executor, so this adds no serialization the upload didn't have.
        from pipeline.jobs import _EXECUTOR
        from pipeline.handwriting import is_available
        loop = asyncio.get_running_loop()
        if not await loop.run_in_executor(_EXECUTOR, is_available):
            raise HTTPException(
                status_code=400,
                detail=(
                    "Handwriting mode needs extra packages. In the project venv "
                    "run: pip install transformers torch  (first use also "
                    "downloads the TrOCR model, a few hundred MB)."
                ),
            )

    pdf_bytes = await file.read()

    # create_job opens the PDF with PyMuPDF to validate + count pages. Run it on
    # the single OCR worker thread so ALL fitz access stays on one thread β€”
    # PyMuPDF is not thread-safe even across separate Documents, and the event
    # loop could otherwise call fitz.open here concurrently with another job's
    # render on the worker thread (multi-file uploads run concurrently).
    from pipeline.jobs import _EXECUTOR
    loop = asyncio.get_running_loop()
    try:
        job = await loop.run_in_executor(
            _EXECUTOR,
            lambda: manager.create_job(
                file.filename or "document.pdf",
                pdf_bytes,
                mode=mode,
                lang=lang,
                preprocess=pre,
                binarize=binar,
                handwriting=handw,
                online=onl,
                online_key=okey,
                online_model=omodel,
                force_ocr=force,
                remove_headers=rm_headers,
            ),
        )
    except ValueError as exc:
        # encrypted / corrupt / otherwise unreadable PDF
        raise HTTPException(status_code=400, detail=str(exc))

    # Launch processing as a background task; progress is streamed over SSE.
    _spawn(manager.run(job))

    return {
        "job_id": job.job_id,
        "filename": job.filename,
        "total_pages": job.total_pages,
        "status": job.status,
    }


@app.post("/api/online/validate")
async def online_validate(api_key: str = Form(...)):
    """Validate a Gemini API key and return the vision models it can use.

    Lets the UI confirm the key works (and populate the model dropdown) before
    the user runs a whole document. Never stores the key server-side.
    """
    from pipeline import online_ocr

    def _check():
        all_models = online_ocr.list_models(api_key)
        supported = [m for m in online_ocr.SUPPORTED_MODELS if m in set(all_models)]
        return {
            "ok": True,
            "supported": supported,
            "recommended": online_ocr.best_available_model(all_models),
        }

    loop = asyncio.get_running_loop()
    try:
        return await loop.run_in_executor(None, _check)
    except RuntimeError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    except Exception:  # never surface a bare 500 from a key check
        raise HTTPException(
            status_code=502, detail="Online key check failed unexpectedly."
        )


@app.post("/api/analyze")
async def analyze(
    file: UploadFile = File(...),
    lang: str = Form("en"),
):
    """Sample 1-2 pages and recommend the best settings for THIS document.

    Opt-in and fast. Runs the CPU-bound sweep in the OCR worker thread pool so
    it never stalls the event loop / SSE streams, and so it never runs OCR
    concurrently with a live extraction job (PaddleOCR is not thread-safe).
    """
    pdf_bytes = await file.read()
    if not pdf_bytes:
        raise HTTPException(status_code=400, detail="empty file")
    from pipeline.jobs import _EXECUTOR
    loop = asyncio.get_running_loop()
    result = await loop.run_in_executor(
        _EXECUTOR, lambda: suggest_settings(pdf_bytes, lang=lang)
    )
    return result


@app.post("/api/export/docx")
async def export_docx(payload: dict = Body(...)):
    """Build a .docx from the posted extracted documents and stream it back."""
    documents = payload.get("documents", []) if isinstance(payload, dict) else []
    loop = asyncio.get_running_loop()
    # build_docx is hardened to never raise on a malformed/hostile payload, but
    # keep a belt-and-suspenders guard so a single bad export can't 500.
    try:
        data = await loop.run_in_executor(None, build_docx, documents)
    except Exception:
        raise HTTPException(status_code=400, detail="Could not build the Word document.")
    return StreamingResponse(
        io.BytesIO(data),
        media_type=(
            "application/vnd.openxmlformats-officedocument."
            "wordprocessingml.document"
        ),
        headers={"Content-Disposition": 'attachment; filename="extraction.docx"'},
    )


@app.get("/api/jobs/{job_id}")
async def get_job(job_id: str):
    """Return the current full state of a job."""
    job = manager.get(job_id)
    if job is None:
        raise HTTPException(status_code=404, detail="job not found")
    return job.to_dict()


@app.get("/api/jobs/{job_id}/stream")
async def stream_job(job_id: str):
    """Stream per-page progress events as Server-Sent Events."""
    job = manager.get(job_id)
    if job is None:
        raise HTTPException(status_code=404, detail="job not found")

    async def event_generator():
        async for event in manager.events(job_id):
            yield f"data: {json.dumps(event)}\n\n"
            etype = event.get("type")
            if etype in ("done", "error", "cancelled"):
                break

    return StreamingResponse(event_generator(), media_type="text/event-stream")


@app.post("/api/jobs/{job_id}/cancel")
async def cancel_job(job_id: str):
    """Request cancellation of a running job."""
    job = manager.get(job_id)
    if job is None:
        raise HTTPException(status_code=404, detail="job not found")
    manager.cancel(job_id)
    return {"status": "cancelling"}


@app.delete("/api/jobs/{job_id}")
async def delete_job(job_id: str):
    """Remove a job and free its in-memory PDF bytes. Idempotent (always 200)."""
    manager.delete(job_id)
    return {"status": "deleted"}


@app.get("/api/jobs/{job_id}/pages/{n}/image")
async def page_image(job_id: str, n: int, dpi: int = 150):
    """Render a 1-based page to PNG and stream it back."""
    job = manager.get(job_id)
    if job is None:
        raise HTTPException(status_code=404, detail="job not found")
    # Clamp DPI to a sane range so a degenerate ?dpi= (0, negative, or huge)
    # can't crash the renderer (fitz error / giant allocation) into a 500.
    dpi = max(36, min(600, dpi))
    try:
        # PNG raster + encode is CPU-bound; run it off the event loop. Use the
        # SAME single-worker OCR executor so PyMuPDF rendering never runs
        # concurrently with an OCR page render or another image render (fitz is
        # not formally thread-safe even across separate Document objects).
        from pipeline.jobs import _EXECUTOR
        loop = asyncio.get_running_loop()
        png = await loop.run_in_executor(
            _EXECUTOR, manager.render_page_png, job_id, n, dpi
        )
    except (KeyError, IndexError, ValueError):
        raise HTTPException(status_code=404, detail="page not found")
    except Exception:
        # A PyMuPDF render error (e.g. a page with a malformed embedded image
        # raises fitz.FileDataError / RuntimeError) must not surface as a bare
        # 500 β€” the frontend <img> onerror handles a non-image response.
        raise HTTPException(status_code=404, detail="could not render page")
    return StreamingResponse(io.BytesIO(png), media_type="image/png")


# ---------------------------------------------------------------------------
# Knowledge graph (opt-in, online) β€” build a per-document graph from the
# already-extracted text, then answer queries with hybrid semantic + graph-
# traversal retrieval. These endpoints touch NO fitz/PaddleOCR, so they run on
# the DEFAULT thread pool (run_in_executor(None, ...)) β€” putting them on the
# single OCR worker would needlessly serialize them behind live extraction.
# ---------------------------------------------------------------------------
def _resolve_kg_key(explicit: str, job) -> str:
    """Pick the Gemini key for KG: explicit (browser) -> the job's own key -> demo."""
    return (
        (explicit or "").strip()
        or (getattr(job, "online_key", "") or "").strip()
        or os.environ.get("DEMO_GEMINI_KEY", "").strip()
    )


@app.post("/api/graph/build")
async def graph_build(
    job_id: str = Form(...),
    online_key: str = Form(""),
    online_model: str = Form(""),
    embed: str = Form("true"),
    max_pages: str = Form(""),
):
    """Build a knowledge graph from a finished job's extracted text."""
    job = manager.get(job_id)
    if job is None:
        raise HTTPException(status_code=404, detail="job not found")
    if job.status not in ("done", "error"):
        raise HTTPException(
            status_code=409,
            detail="Extraction is still running; wait for it to finish first.",
        )
    if job.kg_status == "building":
        raise HTTPException(
            status_code=409,
            detail="A knowledge graph is already being built for this document.",
        )

    okey = _resolve_kg_key(online_key, job)
    if not okey:
        raise HTTPException(
            status_code=400,
            detail=(
                "Building a knowledge graph needs a Gemini API key. Enter your "
                "free key from aistudio.google.com (the same key online OCR uses)."
            ),
        )

    pages = [
        (p.page, p.text)
        for p in job.pages
        if p.status == "done" and (p.text or "").strip()
    ]
    if not pages:
        raise HTTPException(
            status_code=400, detail="No extracted text to build a graph from."
        )

    mp = None
    if (max_pages or "").strip():
        try:
            mp = max(0, int(max_pages))
        except ValueError:
            mp = None
    do_embed = _parse_bool(embed)

    from pipeline import kg as kgmod

    job.kg_status = "building"
    job.kg_error = None
    loop = asyncio.get_running_loop()
    try:
        graph = await loop.run_in_executor(
            None,
            lambda: kgmod.build_graph(
                pages,
                api_key=okey,
                triple_model=(online_model or None),
                max_pages=mp,
                embed=do_embed,
            ),
        )
        # Set the terminal state INSIDE the try so the finally below sees it.
        job.knowledge_graph = graph
        job.kg_status = "ready"
    except RuntimeError as exc:
        # A Gemini/key/quota/network failure β€” actionable, single-line message.
        job.kg_status = "error"
        job.kg_error = str(exc)
        raise HTTPException(status_code=400, detail=str(exc))
    except Exception:
        job.kg_status = "error"
        job.kg_error = "Knowledge-graph build failed unexpectedly."
        raise HTTPException(
            status_code=502, detail="Knowledge-graph build failed unexpectedly."
        )
    finally:
        # A client disconnect raises asyncio.CancelledError β€” a BaseException
        # that bypasses the except clauses above (they catch only Exception).
        # Without this, kg_status would stay stuck on "building" and 409-lock
        # every future rebuild of this document until it's evicted. Reset it.
        if job.kg_status == "building":
            job.kg_status = "error"
            job.kg_error = "Build was interrupted before it finished; try again."

    return {
        "job_id": job_id,
        "status": "ready",
        "nodes": graph.num_nodes,
        "edges": graph.num_edges,
        "has_vectors": graph.has_vectors,
        "pages_built": graph.pages_built,
        "pages_failed": graph.pages_failed,
        "embed_error": graph.embed_error,
    }


@app.post("/api/graph/query")
async def graph_query(
    job_id: str = Form(...),
    query: str = Form(...),
    online_key: str = Form(""),
    top_k: int = Form(6),
    hops: int = Form(2),
):
    """Answer a query against a built graph: ranked entities + supporting paths."""
    job = manager.get(job_id)
    if job is None:
        raise HTTPException(status_code=404, detail="job not found")
    graph = job.knowledge_graph
    if graph is None:
        raise HTTPException(
            status_code=409, detail="No knowledge graph has been built for this document yet."
        )
    q = (query or "").strip()
    if not q:
        raise HTTPException(status_code=400, detail="Enter a question to search the graph.")

    okey = _resolve_kg_key(online_key, job)  # optional: enables semantic search
    loop = asyncio.get_running_loop()
    try:
        result = await loop.run_in_executor(
            None,
            lambda: graph.search(
                q,
                api_key=(okey or None),
                top_k=max(1, min(20, int(top_k))),
                hops=max(0, min(3, int(hops))),
            ),
        )
    except RuntimeError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    except Exception:
        raise HTTPException(status_code=502, detail="Graph query failed unexpectedly.")
    return result


@app.get("/api/graph/{job_id}")
async def graph_get(job_id: str, full: bool = False):
    """Return the built graph.

    Default: compact node/edge lists for the canvas visualization (capped).
    ``?full=1``: the complete entities + triples + provenance for .json export.
    """
    job = manager.get(job_id)
    if job is None:
        raise HTTPException(status_code=404, detail="job not found")
    graph = job.knowledge_graph
    if graph is None:
        return {
            "status": job.kg_status,
            "error": job.kg_error,
            "nodes": [],
            "edges": [],
            "total_nodes": 0,
            "total_edges": 0,
            "truncated": False,
        }
    if full:
        data = graph.to_export_dict()
        data["status"] = "ready"
        return data
    data = graph.to_viz_dict()
    data["status"] = "ready"
    return data