github-actions[bot] commited on
Commit
62ba81f
·
1 Parent(s): 62d33a7

Sync backend files from GitHub 66452e1

Browse files

Deployed-From: 66452e148f1e95cfb40220989f5b1cf28967c829

.github/workflows/sync-to-hf.yml CHANGED
@@ -1,20 +1,99 @@
1
  name: Sync to Hugging Face Hub
2
 
 
 
 
 
 
 
3
  on:
4
  push:
5
  branches: [ main ]
6
  workflow_dispatch:
7
 
8
  jobs:
9
- sync:
 
10
  runs-on: ubuntu-latest
 
 
11
  steps:
12
  - uses: actions/checkout@v6
13
  with:
14
  fetch-depth: 0
15
  lfs: true
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  - name: Push to HF Space
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  env:
18
  HF_TOKEN: ${{ secrets.HF_TOKEN }}
19
  run: |
20
- git push --force https://user:$HF_TOKEN@huggingface.co/spaces/AccuratePaperExtraction/MADTOOL main
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  name: Sync to Hugging Face Hub
2
 
3
+ # GitHub main is the source of truth for both Spaces:
4
+ # - mppolak/madtools (madtools.org) runs the website and gets the repo as-is.
5
+ # - AccuratePaperExtraction/MADTOOL only displays the backend files: it gets a
6
+ # snapshot without index.html, with deploy/backend-space-README.md as README.md.
7
+ # The two jobs are independent, so one failing doesn't block the other.
8
+
9
  on:
10
  push:
11
  branches: [ main ]
12
  workflow_dispatch:
13
 
14
  jobs:
15
+ website:
16
+ name: sync website (mppolak/madtools)
17
  runs-on: ubuntu-latest
18
+ env:
19
+ SPACE: mppolak/madtools
20
  steps:
21
  - uses: actions/checkout@v6
22
  with:
23
  fetch-depth: 0
24
  lfs: true
25
+
26
+ - name: Block deploy if the Space has commits GitHub doesn't
27
+ env:
28
+ HF_TOKEN: ${{ secrets.HF_TOKEN_MADTOOLS }}
29
+ run: |
30
+ git remote add hf https://user:$HF_TOKEN@huggingface.co/spaces/$SPACE
31
+ git fetch hf main
32
+ AHEAD=$(git rev-list --count HEAD..hf/main)
33
+ if [ "$AHEAD" -gt 0 ]; then
34
+ echo "::error::The Space $SPACE has $AHEAD commit(s) not on GitHub. Deploy blocked to avoid discarding them."
35
+ echo "Commits only on the Space:"
36
+ git log --format=' %h %an %s' HEAD..hf/main
37
+ echo ""
38
+ echo "To reconcile, from a local clone of this repo:"
39
+ echo " git fetch https://huggingface.co/spaces/$SPACE main"
40
+ echo " git merge FETCH_HEAD # review the diff, resolve, or revert what you don't want"
41
+ echo " git push origin main # re-runs this workflow, which will then fast-forward the Space"
42
+ exit 1
43
+ fi
44
+ echo "Space $SPACE is in sync with GitHub; proceeding."
45
+
46
  - name: Push to HF Space
47
+ run: git push hf HEAD:main
48
+
49
+ backend-files:
50
+ name: sync backend files (AccuratePaperExtraction/MADTOOL)
51
+ runs-on: ubuntu-latest
52
+ env:
53
+ SPACE: AccuratePaperExtraction/MADTOOL
54
+ steps:
55
+ - uses: actions/checkout@v6
56
+ with:
57
+ fetch-depth: 0
58
+ lfs: true
59
+
60
+ - name: Block deploy if the Space was edited directly
61
  env:
62
  HF_TOKEN: ${{ secrets.HF_TOKEN }}
63
  run: |
64
+ git remote add hf https://user:$HF_TOKEN@huggingface.co/spaces/$SPACE
65
+ git fetch hf main
66
+ # The Space's history holds snapshots this job made (marked with a
67
+ # Deployed-From trailer); any other commit GitHub lacks is a manual edit.
68
+ MANUAL=$(git log --format='%h %(trailers:key=Deployed-From,valueonly,separator=)' HEAD..hf/main | awk 'NF==1 {print $1}')
69
+ if [ -n "$MANUAL" ]; then
70
+ echo "::error::The Space $SPACE has commits made directly on it. Deploy blocked to avoid discarding them."
71
+ for c in $MANUAL; do git log -1 --format=' %h %an %s' "$c"; done
72
+ echo ""
73
+ echo "To reconcile, from a local clone of this repo, bring any wanted changes into GitHub"
74
+ echo "(the README lives in deploy/backend-space-README.md), then record the edits as merged:"
75
+ echo " git fetch https://huggingface.co/spaces/$SPACE main"
76
+ echo " git merge -s ours FETCH_HEAD"
77
+ echo " git push origin main"
78
+ exit 1
79
+ fi
80
+ echo "Space $SPACE has no direct edits; proceeding."
81
+
82
+ - name: Build backend-files snapshot and push
83
+ run: |
84
+ export GIT_INDEX_FILE="$RUNNER_TEMP/backend-files.index"
85
+ git read-tree HEAD
86
+ git rm --cached --quiet --ignore-unmatch index.html deploy/backend-space-README.md
87
+ git update-index --add --cacheinfo 100644,"$(git hash-object -w deploy/backend-space-README.md)",README.md
88
+ TREE=$(git write-tree)
89
+ unset GIT_INDEX_FILE
90
+ if [ "$TREE" = "$(git rev-parse 'hf/main^{tree}')" ]; then
91
+ echo "Space $SPACE already has these files; nothing to push."
92
+ exit 0
93
+ fi
94
+ COMMIT=$(git -c user.name="github-actions[bot]" \
95
+ -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \
96
+ commit-tree "$TREE" -p hf/main \
97
+ -m "Sync backend files from GitHub ${GITHUB_SHA::7}" \
98
+ -m "Deployed-From: $GITHUB_SHA")
99
+ git push hf "$COMMIT:refs/heads/main"
main.py CHANGED
@@ -717,21 +717,50 @@ def provider_display_name(provider: str) -> str:
717
  return {"openai": "OpenAI", "gemini": "Gemini"}.get(provider, provider or "Unknown")
718
 
719
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
720
  def list_openai_chat_models(api_key: str) -> List[Dict[str, str]]:
721
  if OpenAIClient is None:
722
  raise RuntimeError("openai SDK is not installed.")
723
  client = OpenAIClient(api_key=api_key)
724
  page = client.models.list()
725
- out: List[Dict[str, str]] = []
726
  seen = set()
727
  for item in getattr(page, "data", []) or []:
728
  model_id = getattr(item, "id", None) or (item.get("id") if isinstance(item, dict) else None)
729
  if not model_id or model_id in seen:
730
  continue
731
- if "gpt" not in model_id.lower():
732
  continue
733
  seen.add(model_id)
734
- out.append({"id": model_id, "display_name": model_id})
 
 
 
 
 
735
  return out
736
 
737
 
@@ -748,6 +777,45 @@ def _fetch_gemini_model_list(api_key: str) -> List[Dict[str, Any]]:
748
  return payload.get("models", []) or []
749
 
750
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751
  def list_gemini_models(api_key: str) -> List[Dict[str, str]]:
752
  """List Gemini chat models via the public REST API to avoid pinning conflicts
753
  between google-generativeai and langchain-google-genai.
@@ -762,9 +830,12 @@ def list_gemini_models(api_key: str) -> List[Dict[str, str]]:
762
  model_id = raw_id.split("/", 1)[-1] if raw_id else ""
763
  if not model_id or model_id in seen:
764
  continue
 
 
765
  seen.add(model_id)
766
  display = item.get("displayName") or model_id
767
  out.append({"id": model_id, "display_name": str(display)})
 
768
  return out
769
 
770
 
@@ -808,32 +879,10 @@ def fetch_models_for_provider(provider: str, api_key: str) -> List[Dict[str, str
808
  return []
809
 
810
 
811
- def _date_key(text: str) -> int:
812
- value = (text or "").lower()
813
- compact = re.search(r"(20\d{2})(\d{2})(\d{2})", value)
814
- if compact:
815
- return int("".join(compact.groups()))
816
- dashed = re.search(r"(20\d{2})[-_](\d{2})[-_](\d{2})", value)
817
- if dashed:
818
- return int("".join(dashed.groups()))
819
- return -1
820
-
821
-
822
  def choose_default_model(models: List[Dict[str, str]], provider: str) -> str:
823
- if not models:
824
- return ""
825
- def priority(m):
826
- mid = (m.get("id") or "").lower()
827
- return (1 if "latest" in mid else 0, _date_key(mid), mid)
828
- if provider == "openai":
829
- gpt5 = [m for m in models if "gpt-5" in (m.get("id") or "").lower()]
830
- if gpt5:
831
- return max(gpt5, key=priority)["id"]
832
- if provider == "gemini":
833
- pros = [m for m in models if "pro" in (m.get("id") or "").lower()]
834
- if pros:
835
- return max(pros, key=priority)["id"]
836
- return max(models, key=priority)["id"]
837
 
838
  class AsyncEmbeddingAdapter:
839
  def __init__(self, embedder):
 
717
  return {"openai": "OpenAI", "gemini": "Gemini"}.get(provider, provider or "Unknown")
718
 
719
 
720
+ # Substrings of OpenAI model ids that are not text-chat models (same list
721
+ # PlotExtract uses, plus completions-only "instruct" models). "search" also
722
+ # covers the tool-only deep-research models.
723
+ OPENAI_NON_CHAT_MARKERS = (
724
+ "audio",
725
+ "dall-e",
726
+ "embedding",
727
+ "gpt-image",
728
+ "instruct",
729
+ "moderation",
730
+ "realtime",
731
+ "search",
732
+ "transcribe",
733
+ "tts",
734
+ "whisper",
735
+ )
736
+
737
+
738
+ def is_openai_chat_model(model_id: str) -> bool:
739
+ mid = model_id.lower()
740
+ is_family = "gpt" in mid or re.match(r"^o\d+(?:-|$)", mid) # GPT and o-series reasoning models
741
+ return bool(is_family) and not any(marker in mid for marker in OPENAI_NON_CHAT_MARKERS)
742
+
743
+
744
  def list_openai_chat_models(api_key: str) -> List[Dict[str, str]]:
745
  if OpenAIClient is None:
746
  raise RuntimeError("openai SDK is not installed.")
747
  client = OpenAIClient(api_key=api_key)
748
  page = client.models.list()
749
+ out: List[Dict[str, Any]] = []
750
  seen = set()
751
  for item in getattr(page, "data", []) or []:
752
  model_id = getattr(item, "id", None) or (item.get("id") if isinstance(item, dict) else None)
753
  if not model_id or model_id in seen:
754
  continue
755
+ if not is_openai_chat_model(model_id):
756
  continue
757
  seen.add(model_id)
758
+ created = getattr(item, "created", None) or (item.get("created") if isinstance(item, dict) else None)
759
+ out.append({"id": model_id, "display_name": model_id, "created": created or 0})
760
+ # Newest first by release timestamp
761
+ out.sort(key=lambda m: (m["created"], m["id"]), reverse=True)
762
+ for m in out:
763
+ m.pop("created")
764
  return out
765
 
766
 
 
777
  return payload.get("models", []) or []
778
 
779
 
780
+ # Substrings of Gemini model ids that support generateContent but are not
781
+ # text-chat models (speech, image generation, live audio, tool-only agents).
782
+ GEMINI_NON_CHAT_MARKERS = (
783
+ "aqa",
784
+ "audio",
785
+ "computer-use",
786
+ "embedding",
787
+ "image",
788
+ "live",
789
+ "robotics",
790
+ "tts",
791
+ )
792
+
793
+
794
+ def gemini_sort_key(model_id: str) -> Tuple:
795
+ """Newest-first ordering for Gemini ids, which carry no release date: the
796
+ version number tracks release order, then pro > flash > flash-lite, then
797
+ stable before preview/experimental. "-latest" aliases float to the top.
798
+ """
799
+ mid = model_id.lower()
800
+ version = re.search(r"-(\d+(?:\.\d+)?)(?:-|$)", mid)
801
+ if "lite" in mid:
802
+ tier = 1
803
+ elif "flash" in mid:
804
+ tier = 2
805
+ elif "pro" in mid:
806
+ tier = 3
807
+ else:
808
+ tier = 0
809
+ return (
810
+ "latest" in mid,
811
+ mid.startswith("gemini"),
812
+ float(version.group(1)) if version else 0.0,
813
+ tier,
814
+ not any(tag in mid for tag in ("preview", "exp")),
815
+ mid,
816
+ )
817
+
818
+
819
  def list_gemini_models(api_key: str) -> List[Dict[str, str]]:
820
  """List Gemini chat models via the public REST API to avoid pinning conflicts
821
  between google-generativeai and langchain-google-genai.
 
830
  model_id = raw_id.split("/", 1)[-1] if raw_id else ""
831
  if not model_id or model_id in seen:
832
  continue
833
+ if any(marker in model_id.lower() for marker in GEMINI_NON_CHAT_MARKERS):
834
+ continue
835
  seen.add(model_id)
836
  display = item.get("displayName") or model_id
837
  out.append({"id": model_id, "display_name": str(display)})
838
+ out.sort(key=lambda m: gemini_sort_key(m["id"]), reverse=True)
839
  return out
840
 
841
 
 
879
  return []
880
 
881
 
 
 
 
 
 
 
 
 
 
 
 
882
  def choose_default_model(models: List[Dict[str, str]], provider: str) -> str:
883
+ # The provider lists are already sorted newest-first, so the default is
884
+ # simply the top entry.
885
+ return models[0]["id"] if models else ""
 
 
 
 
 
 
 
 
 
 
 
886
 
887
  class AsyncEmbeddingAdapter:
888
  def __init__(self, embedder):
plotextract/app.py CHANGED
@@ -458,51 +458,50 @@ def fetch_all_gemini_models(client):
458
  return [model for model in models if is_gemini_vision_model(model)]
459
 
460
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
  def fetch_all_models(client, provider):
462
  if provider == "anthropic":
463
- return fetch_all_anthropic_models(client)
464
  if provider == "openai":
465
- return fetch_all_openai_models(client)
466
  if provider == "gemini":
467
- return fetch_all_gemini_models(client)
468
  return []
469
 
470
 
471
- def extract_date_key(text):
472
- value = (text or "").lower()
473
- compact = re.search(r"(20\d{2})(\d{2})(\d{2})", value)
474
- if compact:
475
- return int("".join(compact.groups()))
476
- dashed = re.search(r"(20\d{2})[-_](\d{2})[-_](\d{2})", value)
477
- if dashed:
478
- return int("".join(dashed.groups()))
479
- return -1
480
-
481
-
482
- def model_priority(model):
483
- model_id = (model.get("id") or "").lower()
484
- is_latest_alias = 1 if "latest" in model_id else 0
485
- date_key = max(extract_date_key(model_id), extract_date_key(model.get("created_at") or ""))
486
- created_key = model.get("created_at") or ""
487
- return (is_latest_alias, date_key, created_key, model_id)
488
-
489
-
490
  def choose_default_model(models, provider):
491
- if provider == "anthropic":
492
- sonnets = [model for model in models if "sonnet" in (model.get("id") or "").lower()]
493
- if sonnets:
494
- return max(sonnets, key=model_priority)["id"]
495
- if provider == "openai":
496
- gpt_models = [model for model in models if "gpt" in (model.get("id") or "").lower()]
497
- if gpt_models:
498
- return max(gpt_models, key=model_priority)["id"]
499
- if provider == "gemini":
500
- pro_models = [model for model in models if "pro" in (model.get("id") or "").lower()]
501
- if pro_models:
502
- return max(pro_models, key=model_priority)["id"]
503
- if models:
504
- return max(models, key=model_priority)["id"]
505
- return ""
506
 
507
 
508
  def media_type_for_path(image_path):
 
458
  return [model for model in models if is_gemini_vision_model(model)]
459
 
460
 
461
+ def gemini_sort_key(model):
462
+ # Gemini lists carry no release date: the version number tracks release
463
+ # order, then pro > flash > flash-lite, then stable before preview.
464
+ # "-latest" aliases float to the top.
465
+ model_id = (model.get("id") or "").lower()
466
+ version = re.search(r"-(\d+(?:\.\d+)?)(?:-|$)", model_id)
467
+ if "lite" in model_id:
468
+ tier = 1
469
+ elif "flash" in model_id:
470
+ tier = 2
471
+ elif "pro" in model_id:
472
+ tier = 3
473
+ else:
474
+ tier = 0
475
+ return (
476
+ "latest" in model_id,
477
+ float(version.group(1)) if version else 0.0,
478
+ tier,
479
+ not any(tag in model_id for tag in ["preview", "exp"]),
480
+ model_id,
481
+ )
482
+
483
+
484
+ def sort_models_newest_first(models, provider):
485
+ if provider == "gemini":
486
+ return sorted(models, key=gemini_sort_key, reverse=True)
487
+ # Anthropic and OpenAI report a creation time (UTC ISO strings sort correctly)
488
+ return sorted(models, key=lambda model: (model.get("created_at") or "", model.get("id") or ""), reverse=True)
489
+
490
+
491
  def fetch_all_models(client, provider):
492
  if provider == "anthropic":
493
+ return sort_models_newest_first(fetch_all_anthropic_models(client), provider)
494
  if provider == "openai":
495
+ return sort_models_newest_first(fetch_all_openai_models(client), provider)
496
  if provider == "gemini":
497
+ return sort_models_newest_first(fetch_all_gemini_models(client), provider)
498
  return []
499
 
500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  def choose_default_model(models, provider):
502
+ # fetch_all_models returns lists sorted newest-first, so the default is
503
+ # simply the top entry.
504
+ return models[0]["id"] if models else ""
 
 
 
 
 
 
 
 
 
 
 
 
505
 
506
 
507
  def media_type_for_path(image_path):
plotextract/static/app.js CHANGED
@@ -22,6 +22,9 @@ const copyCodeBtn = document.getElementById("copyCode");
22
  const toggleKeyBtn = document.getElementById("toggleKey");
23
  const zipDownload = document.getElementById("zipDownload");
24
  const resultCards = document.getElementById("resultCards");
 
 
 
25
  const APP_BASE = document.body?.dataset?.appBase || "";
26
  const appUrl = (path) => `${APP_BASE}${path}`;
27
 
@@ -33,6 +36,7 @@ const MODEL_PLACEHOLDER_ERROR = "Could not load models";
33
 
34
  let currentFile = null;
35
  let isZipUpload = false;
 
36
  let pollTimer = null;
37
  let loadedModelsKey = "";
38
  let modelsLoaded = false;
@@ -276,24 +280,33 @@ const setErrorDetails = (text) => {
276
  errorDetails.style.display = "block";
277
  };
278
 
 
 
 
 
 
 
279
  const setPreview = (file) => {
 
280
  if (!file) {
281
  preview.innerHTML = "<p class=\"hint\">Preview will appear here.</p>";
282
  return;
283
  }
284
  const url = URL.createObjectURL(file);
 
285
  preview.innerHTML = "";
286
  const img = document.createElement("img");
287
  img.src = url;
288
- img.onload = () => URL.revokeObjectURL(url);
289
  preview.appendChild(img);
290
  };
291
 
292
  const setPreviewMessage = (text) => {
 
293
  preview.innerHTML = `<p class="hint">${text}</p>`;
294
  };
295
 
296
  const setZipPreview = (fileName) => {
 
297
  preview.innerHTML = `
298
  <div class="zip-preview">
299
  <svg viewBox="0 0 24 24" aria-hidden="true">
@@ -2626,6 +2639,32 @@ setModelPlaceholder(MODEL_PLACEHOLDER_NO_KEY);
2626
 
2627
  improveManualBtn?.addEventListener("click", startManualImprove);
2628
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2629
  imageInput.addEventListener("change", (event) => {
2630
  const file = event.target.files[0];
2631
  acceptFile(file);
 
22
  const toggleKeyBtn = document.getElementById("toggleKey");
23
  const zipDownload = document.getElementById("zipDownload");
24
  const resultCards = document.getElementById("resultCards");
25
+ const infoBtn = document.getElementById("infoBtn");
26
+ const infoModal = document.getElementById("infoModal");
27
+ const infoCloseBtn = document.getElementById("infoCloseBtn");
28
  const APP_BASE = document.body?.dataset?.appBase || "";
29
  const appUrl = (path) => `${APP_BASE}${path}`;
30
 
 
36
 
37
  let currentFile = null;
38
  let isZipUpload = false;
39
+ let currentPreviewObjectUrl = "";
40
  let pollTimer = null;
41
  let loadedModelsKey = "";
42
  let modelsLoaded = false;
 
280
  errorDetails.style.display = "block";
281
  };
282
 
283
+ const releasePreviewObjectUrl = () => {
284
+ if (!currentPreviewObjectUrl) return;
285
+ URL.revokeObjectURL(currentPreviewObjectUrl);
286
+ currentPreviewObjectUrl = "";
287
+ };
288
+
289
  const setPreview = (file) => {
290
+ releasePreviewObjectUrl();
291
  if (!file) {
292
  preview.innerHTML = "<p class=\"hint\">Preview will appear here.</p>";
293
  return;
294
  }
295
  const url = URL.createObjectURL(file);
296
+ currentPreviewObjectUrl = url;
297
  preview.innerHTML = "";
298
  const img = document.createElement("img");
299
  img.src = url;
 
300
  preview.appendChild(img);
301
  };
302
 
303
  const setPreviewMessage = (text) => {
304
+ releasePreviewObjectUrl();
305
  preview.innerHTML = `<p class="hint">${text}</p>`;
306
  };
307
 
308
  const setZipPreview = (fileName) => {
309
+ releasePreviewObjectUrl();
310
  preview.innerHTML = `
311
  <div class="zip-preview">
312
  <svg viewBox="0 0 24 24" aria-hidden="true">
 
2639
 
2640
  improveManualBtn?.addEventListener("click", startManualImprove);
2641
 
2642
+ const openInfoModal = () => {
2643
+ if (!infoModal) return;
2644
+ infoModal.classList.remove("hidden");
2645
+ infoCloseBtn?.focus();
2646
+ };
2647
+
2648
+ const closeInfoModal = () => {
2649
+ if (!infoModal) return;
2650
+ infoModal.classList.add("hidden");
2651
+ infoBtn?.focus();
2652
+ };
2653
+
2654
+ infoBtn?.addEventListener("click", openInfoModal);
2655
+ infoCloseBtn?.addEventListener("click", closeInfoModal);
2656
+ infoModal?.addEventListener("click", (event) => {
2657
+ if (event.target === infoModal) {
2658
+ closeInfoModal();
2659
+ }
2660
+ });
2661
+
2662
+ document.addEventListener("keydown", (event) => {
2663
+ if (event.key === "Escape" && infoModal && !infoModal.classList.contains("hidden")) {
2664
+ closeInfoModal();
2665
+ }
2666
+ });
2667
+
2668
  imageInput.addEventListener("change", (event) => {
2669
  const file = event.target.files[0];
2670
  acceptFile(file);
plotextract/static/styles.css CHANGED
@@ -51,9 +51,18 @@ body {
51
 
52
  .hero {
53
  display: grid;
54
- grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
55
  gap: 24px;
56
- align-items: center;
 
 
 
 
 
 
 
 
 
57
  }
58
 
59
  .brand {
@@ -639,6 +648,65 @@ input[type="file"] {
639
  border-radius: 9px;
640
  }
641
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
642
  .manual-modal-actions {
643
  display: flex;
644
  justify-content: flex-end;
@@ -892,7 +960,24 @@ textarea {
892
  font-size: 0.9rem;
893
  }
894
 
 
 
 
 
 
 
 
 
 
895
  @media (max-width: 720px) {
 
 
 
 
 
 
 
 
896
  .actions {
897
  flex-direction: column;
898
  align-items: flex-start;
 
51
 
52
  .hero {
53
  display: grid;
54
+ grid-template-columns: minmax(260px, 1fr) auto;
55
  gap: 24px;
56
+ align-items: start;
57
+ }
58
+
59
+ .hero-actions {
60
+ display: flex;
61
+ justify-content: flex-end;
62
+ }
63
+
64
+ .info-button {
65
+ min-width: 122px;
66
  }
67
 
68
  .brand {
 
648
  border-radius: 9px;
649
  }
650
 
651
+ .info-modal-backdrop {
652
+ position: fixed;
653
+ inset: 0;
654
+ z-index: 40;
655
+ display: grid;
656
+ place-items: center;
657
+ padding: 24px;
658
+ background: rgba(2, 6, 23, 0.78);
659
+ backdrop-filter: blur(8px);
660
+ }
661
+
662
+ .info-modal {
663
+ width: min(680px, 100%);
664
+ max-height: min(760px, 88vh);
665
+ overflow: auto;
666
+ padding: 24px;
667
+ border: 1px solid rgba(148, 163, 184, 0.28);
668
+ border-radius: 16px;
669
+ background: rgba(15, 23, 42, 0.97);
670
+ box-shadow: var(--shadow);
671
+ }
672
+
673
+ .info-modal-head {
674
+ display: flex;
675
+ align-items: center;
676
+ justify-content: space-between;
677
+ gap: 16px;
678
+ margin-bottom: 16px;
679
+ }
680
+
681
+ .info-modal h2,
682
+ .info-modal h3 {
683
+ margin: 0;
684
+ }
685
+
686
+ .info-modal h3 {
687
+ font-size: 1rem;
688
+ }
689
+
690
+ .info-modal-body {
691
+ display: grid;
692
+ gap: 18px;
693
+ }
694
+
695
+ .info-modal p,
696
+ .info-modal li {
697
+ color: var(--muted);
698
+ line-height: 1.55;
699
+ }
700
+
701
+ .info-modal p {
702
+ margin: 8px 0 0;
703
+ }
704
+
705
+ .info-modal ol {
706
+ margin: 10px 0 0;
707
+ padding-left: 22px;
708
+ }
709
+
710
  .manual-modal-actions {
711
  display: flex;
712
  justify-content: flex-end;
 
960
  font-size: 0.9rem;
961
  }
962
 
963
+ .footer a {
964
+ color: var(--accent-2);
965
+ text-decoration: none;
966
+ }
967
+
968
+ .footer a:hover {
969
+ text-decoration: underline;
970
+ }
971
+
972
  @media (max-width: 720px) {
973
+ .hero {
974
+ grid-template-columns: 1fr;
975
+ }
976
+
977
+ .hero-actions {
978
+ justify-content: flex-start;
979
+ }
980
+
981
  .actions {
982
  flex-direction: column;
983
  align-items: flex-start;
plotextract/templates/index.html CHANGED
@@ -25,6 +25,9 @@
25
  Upload or paste a plot image, supply your Anthropic, OpenAI, or Google Gemini API key, and get back CSV data.
26
  </p>
27
  </div>
 
 
 
28
  </header>
29
 
30
  <section class="panel">
@@ -115,9 +118,52 @@
115
  issues and are not guaranteed to be correct. Always review and verify results before using them for
116
  analysis, reporting, or decision-making.
117
  </p>
 
 
 
 
 
 
 
118
  </footer>
119
  </main>
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  <script src="{{ url_for('static', filename='app.js') }}"></script>
122
  </body>
123
  </html>
 
25
  Upload or paste a plot image, supply your Anthropic, OpenAI, or Google Gemini API key, and get back CSV data.
26
  </p>
27
  </div>
28
+ <div class="hero-actions">
29
+ <button class="ghost info-button" id="infoBtn" type="button">Information</button>
30
+ </div>
31
  </header>
32
 
33
  <section class="panel">
 
118
  issues and are not guaranteed to be correct. Always review and verify results before using them for
119
  analysis, reporting, or decision-making.
120
  </p>
121
+ <p>
122
+ Reference: Maciej P. Polak and Dane Morgan,
123
+ <a href="https://arxiv.org/abs/2503.12326" target="_blank" rel="noopener">
124
+ Leveraging Vision Capabilities of Multimodal LLMs for Automated Data Extraction from Plots
125
+ </a>,
126
+ arXiv:2503.12326.
127
+ </p>
128
  </footer>
129
  </main>
130
 
131
+ <div class="info-modal-backdrop hidden" id="infoModal" role="dialog" aria-modal="true" aria-labelledby="infoTitle">
132
+ <div class="info-modal">
133
+ <div class="info-modal-head">
134
+ <h2 id="infoTitle">Information</h2>
135
+ <button class="ghost info-close" id="infoCloseBtn" type="button" aria-label="Close information">Close</button>
136
+ </div>
137
+ <div class="info-modal-body">
138
+ <section>
139
+ <h3>What this tool does</h3>
140
+ <p>
141
+ PlotExtract uses vision-capable language models to read a plot image, infer the plotted data,
142
+ return CSV data, and generate Python code for a recreated plot.
143
+ </p>
144
+ </section>
145
+ <section>
146
+ <h3>When to use it</h3>
147
+ <p>
148
+ Use it for typical 2D XY plots when you need approximate data from a figure and the original
149
+ data file is not available. It works best with clear axis labels, readable tick values, and visible
150
+ markers or curves.
151
+ </p>
152
+ </section>
153
+ <section>
154
+ <h3>Instructions</h3>
155
+ <ol>
156
+ <li>Enter an Anthropic, OpenAI, or Google Gemini API key and choose a vision-capable model.</li>
157
+ <li>Upload, drag, or paste a PNG/JPG plot image, or upload a ZIP of images.</li>
158
+ <li>Click Extract Data and review the CSV, generated Python, and replotted output.</li>
159
+ <li>Use Improve manually to calibrate axes and drag, add, or remove extracted points.</li>
160
+ <li>Always verify the result before using it for analysis, reporting, or decisions.</li>
161
+ </ol>
162
+ </section>
163
+ </div>
164
+ </div>
165
+ </div>
166
+
167
  <script src="{{ url_for('static', filename='app.js') }}"></script>
168
  </body>
169
  </html>