lfoppiano commited on
Commit
6f06d5d
·
verified ·
1 Parent(s): 0a79409

Upload folder using huggingface_hub

Browse files
.env.example CHANGED
@@ -1,18 +1,17 @@
1
- PHI_URL=....
2
- QWEN_URL=...
 
 
3
 
4
- EMBEDS_URL=...
 
 
 
 
5
  DEFAULT_MODEL=microsoft/Phi-4-mini-instruct
6
  DEFAULT_EMBEDDING=intfloat/multilingual-e5-large-instruct-modal
7
 
8
- API_KEY=...
9
- EMBEDS_API_KEY=...
10
-
11
- GROBID_URL=...
12
- GROBID_QUANTITIES_URL=...
13
-
14
-
15
- QWEN_URL=...
16
- GROBID_MATERIALS_URL=...
17
- API_KEY=...
18
- EMBEDS_API_KEY=...
 
1
+ # ── LLM endpoints (OpenAI-compatible vLLM servers on Modal) ──
2
+ PHI_URL=https://<account>--phi-4-mini-instruct-qa-vllm-serve.modal.run/v1
3
+ QWEN_URL=https://<account>--qwen-0-6b-qa-vllm-serve.modal.run/v1
4
+ API_KEY=your-llm-api-key
5
 
6
+ # ── Embedding endpoint ───────────────────────────────────────
7
+ EMBEDS_URL=https://<account>--intfloat-multilingual-e5-large-instruct-embeddings-embed.modal.run
8
+ EMBEDS_API_KEY=your-embedding-api-key
9
+
10
+ # ── Defaults pre-selected in the UI ──────────────────────────
11
  DEFAULT_MODEL=microsoft/Phi-4-mini-instruct
12
  DEFAULT_EMBEDDING=intfloat/multilingual-e5-large-instruct-modal
13
 
14
+ # ── GROBID services ──────────────────────────────────────────
15
+ GROBID_URL=https://your-grobid-url
16
+ GROBID_QUANTITIES_URL=https://your-grobid-quantities-url/ # optional (measurements NER)
17
+ GROBID_MATERIALS_URL=https://your-grobid-superconductors-url/ # optional (materials NER)
 
 
 
 
 
 
 
README.md CHANGED
@@ -46,6 +46,8 @@ Additionally, this frontend provides the visualisation of named entities on LLM
46
 
47
  **For full technical documentation** of the `document-qa-engine` library **[`docs/README.md`](docs/README.md)**.
48
 
 
 
49
  ### Embedding selection
50
  In the latest version, there is the possibility to select both embedding functions and LLMs. There are some limitations, OpenAI embeddings cannot be used with open source models, and vice-versa.
51
 
@@ -83,7 +85,7 @@ For more information, see the [details](https://docs.trychroma.com/troubleshooti
83
  Please read carefully:
84
 
85
  - Avoid uploading sensitive data. We temporarily store text from the uploaded PDF documents only for processing your request, and we disclaim any responsibility for subsequent use or handling of the submitted data by third-party LLMs.
86
- - Mistral and Zephyr are FREE to use and do not require any API, but as we leverage the free API entrypoint, there is no guarantee that all requests will go through. Use at your own risk.
87
  - We do not assume responsibility for how the data is utilized by the LLM end-points API.
88
 
89
  ## Development notes
 
46
 
47
  **For full technical documentation** of the `document-qa-engine` library **[`docs/README.md`](docs/README.md)**.
48
 
49
+ **To deploy the LLM and embedding endpoints** on Modal.com, see **[`document_qa/deployment/README.md`](document_qa/deployment/README.md)**.
50
+
51
  ### Embedding selection
52
  In the latest version, there is the possibility to select both embedding functions and LLMs. There are some limitations, OpenAI embeddings cannot be used with open source models, and vice-versa.
53
 
 
85
  Please read carefully:
86
 
87
  - Avoid uploading sensitive data. We temporarily store text from the uploaded PDF documents only for processing your request, and we disclaim any responsibility for subsequent use or handling of the submitted data by third-party LLMs.
88
+ - The public demo serves open models (Phi-4-mini-instruct, Qwen3) self-hosted on [Modal.com](https://www.modal.com) under a limited monthly compute budget, so there is no guarantee that all requests will go through. Use at your own risk.
89
  - We do not assume responsibility for how the data is utilized by the LLM end-points API.
90
 
91
  ## Development notes
docs/README.md CHANGED
@@ -97,6 +97,13 @@ GROBID_MATERIALS_URL=https://your-grobid-superconductors-url/
97
  | `GROBID_QUANTITIES_URL` | URL to a grobid-quantities server (for measurement NER) |
98
  | `GROBID_MATERIALS_URL` | URL to a grobid-superconductors server (for materials NER) |
99
 
 
 
 
 
 
 
 
100
  ---
101
 
102
  ## Quick Start — Streamlit App
 
97
  | `GROBID_QUANTITIES_URL` | URL to a grobid-quantities server (for measurement NER) |
98
  | `GROBID_MATERIALS_URL` | URL to a grobid-superconductors server (for materials NER) |
99
 
100
+ ### Deploying the model endpoints
101
+
102
+ The `PHI_URL`, `QWEN_URL`, and `EMBEDS_URL` endpoints above are served by the Modal apps
103
+ in [`../document_qa/deployment/`](../document_qa/deployment/README.md). That README covers
104
+ the required secrets, deploy commands, and how each printed `*.modal.run` URL maps back to
105
+ these variables.
106
+
107
  ---
108
 
109
  ## Quick Start — Streamlit App
document_qa/custom_embeddings.py CHANGED
@@ -47,18 +47,13 @@ class ModalEmbeddings(Embeddings):
47
  # Newlines degrade embedding quality for most models
48
  cleaned_text = [t.replace("\n", " ") for t in text]
49
 
50
- payload = {'text': "\n".join(cleaned_text)}
51
 
52
  headers = {}
53
  if self.api_key:
54
- headers = {'x-api-key': self.api_key}
55
-
56
- response = requests.post(
57
- self.url,
58
- data=payload,
59
- files=[],
60
- headers=headers
61
- )
62
  response.raise_for_status()
63
 
64
  # print(response.text)
@@ -92,12 +87,15 @@ class ModalEmbeddings(Embeddings):
92
 
93
 
94
  if __name__ == "__main__":
 
 
 
 
 
95
  embeds = ModalEmbeddings(
96
- url="https://lfoppiano--intfloat-multilingual-e5-large-instruct-embed-5da184.modal.run/",
97
- model_name="intfloat/multilingual-e5-large-instruct"
 
98
  )
99
 
100
- print(embeds.embed(
101
- ["We are surrounded by stupid kids",
102
- "We are interested in the future of AI"]
103
- ))
 
47
  # Newlines degrade embedding quality for most models
48
  cleaned_text = [t.replace("\n", " ") for t in text]
49
 
50
+ payload = {"text": "\n".join(cleaned_text)}
51
 
52
  headers = {}
53
  if self.api_key:
54
+ headers = {"x-api-key": self.api_key}
55
+
56
+ response = requests.post(self.url, data=payload, files=[], headers=headers)
 
 
 
 
 
57
  response.raise_for_status()
58
 
59
  # print(response.text)
 
87
 
88
 
89
  if __name__ == "__main__":
90
+ # Smoke test against a deployed Modal embedding endpoint. The endpoint requires
91
+ # the x-api-key header, so set EMBEDS_URL and EMBEDS_API_KEY in the environment
92
+ # (see document_qa/deployment/README.md).
93
+ import os
94
+
95
  embeds = ModalEmbeddings(
96
+ url=os.environ["EMBEDS_URL"],
97
+ model_name="intfloat/multilingual-e5-large-instruct",
98
+ api_key=os.environ.get("EMBEDS_API_KEY"),
99
  )
100
 
101
+ print(embeds.embed(["We are surrounded by stupid kids", "We are interested in the future of AI"]))
 
 
 
document_qa/deployment/README.md ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modal deployment scripts
2
+
3
+ This folder contains the [Modal](https://modal.com) apps that serve the LLM and
4
+ embedding endpoints used by document-qa. Each script is an independent Modal app:
5
+ deploy the ones you need, then point the matching `.env` variables at the URLs
6
+ Modal prints.
7
+
8
+ | Script | Modal app | Serves | Maps to `.env` |
9
+ |--------|-----------|--------|----------------|
10
+ | `modal_inference_phi.py` | `phi-4-mini-instruct-qa-vllm` | `microsoft/Phi-4-mini-instruct` (vLLM, OpenAI-compatible) | `PHI_URL` |
11
+ | `modal_inference_qwen.py` | `qwen-0.6b-qa-vllm` | `Qwen/Qwen3-0.6B` (vLLM, reasoning) | `QWEN_URL` |
12
+ | `modal_embeddings_multilang.py` | `intfloat-multilingual-e5-large-instruct-embeddings` | `intfloat/multilingual-e5-large-instruct` | `EMBEDS_URL` |
13
+ | `modal_embeddings_en.py` | `intfloat-e5-large-v2-embeddings` | `intfloat/e5-large-v2` (English-only) | `EMBEDS_URL` |
14
+
15
+ > Both embedding scripts define a tiny global `EmbeddingModel` class that delegates
16
+ > to the shared helpers in `_embeddings_app.py` (`cls_kwargs`, `load_embedding_model`,
17
+ > `run_embed`). The shared module holds the container image and the embedding logic;
18
+ > the model is loaded **once per container** via `@modal.enter()`. To add another
19
+ > embedding model, copy one wrapper and change `MODEL_NAME` / `MODEL_REVISION` / the
20
+ > app name.
21
+
22
+ ## Prerequisites
23
+
24
+ ```bash
25
+ pip install modal
26
+ modal token new # one-time browser auth
27
+ ```
28
+
29
+ ## Secrets
30
+
31
+ The scripts read an `API_KEY` from a Modal [Secret](https://modal.com/docs/guide/secrets).
32
+ Create the two secrets once (the value is the bearer token clients must send):
33
+
34
+ ```bash
35
+ # Used by the inference scripts (phi, qwen)
36
+ modal secret create document-qa-api-key API_KEY=<your-llm-token>
37
+
38
+ # Used by the embedding scripts
39
+ modal secret create document-qa-embedding-key API_KEY=<your-embedding-token>
40
+ ```
41
+
42
+ | Secret | Used by | Provides |
43
+ |--------|---------|----------|
44
+ | `document-qa-api-key` | `modal_inference_phi.py`, `modal_inference_qwen.py` | `API_KEY` for the vLLM `--api-key` flag |
45
+ | `document-qa-embedding-key` | `modal_embeddings_*.py` | `API_KEY` checked against the `x-api-key` header |
46
+
47
+ ## Deploy
48
+
49
+ ```bash
50
+ modal deploy document_qa/deployment/modal_inference_phi.py
51
+ modal deploy document_qa/deployment/modal_inference_qwen.py
52
+ modal deploy document_qa/deployment/modal_embeddings_multilang.py
53
+ # modal deploy document_qa/deployment/modal_embeddings_en.py # optional English-only
54
+ ```
55
+
56
+ Each deploy prints a public `https://<...>.modal.run` URL. Copy it into `.env`:
57
+
58
+ ```env
59
+ PHI_URL=https://<account>--phi-4-mini-instruct-qa-vllm-serve.modal.run/v1
60
+ QWEN_URL=https://<account>--qwen-0-6b-qa-vllm-serve.modal.run/v1
61
+ EMBEDS_URL=https://<account>--intfloat-multilingual-e5-large-instruct-embeddings-embed.modal.run
62
+ API_KEY=<your-llm-token> # matches document-qa-api-key
63
+ EMBEDS_API_KEY=<your-embedding-token> # matches document-qa-embedding-key
64
+ ```
65
+
66
+ > **Inference endpoints** are OpenAI-compatible vLLM servers, so their URLs end in
67
+ > `/v1`. **Embedding endpoints** are a custom form endpoint (see below), so their
68
+ > URL has no `/v1` suffix.
69
+
70
+ ## Endpoint contracts
71
+
72
+ ### Inference (vLLM)
73
+
74
+ Standard OpenAI Chat Completions API at `<PHI_URL|QWEN_URL>`, authenticated with the
75
+ `Authorization: Bearer <API_KEY>` header. Used by `langchain_openai.ChatOpenAI` in
76
+ `streamlit_app.py`.
77
+
78
+ ### Embeddings
79
+
80
+ A custom `POST` endpoint consumed by
81
+ [`ModalEmbeddings`](../custom_embeddings.py):
82
+
83
+ - **Auth**: `x-api-key: <EMBEDS_API_KEY>` header.
84
+ - **Body**: form field `text` with newline-separated strings.
85
+ - **Response**: JSON list of L2-normalised vectors, one per input line.
86
+
87
+ Smoke test:
88
+
89
+ ```bash
90
+ curl -X POST "$EMBEDS_URL" \
91
+ -H "x-api-key: $EMBEDS_API_KEY" \
92
+ -F $'text=first sentence\nsecond sentence'
93
+ ```
94
+
95
+ ## Tuning
96
+
97
+ These knobs live near the top of each script (or in `_embeddings_app.py`):
98
+
99
+ | Setting | Where | Notes |
100
+ |---------|-------|-------|
101
+ | `gpu` | `@app.function` / `@app.cls` | `A10G` is cheaper; `L40S` is faster. Embeddings default to `L40S`, inference to `A10G`. |
102
+ | `scaledown_window` | decorator | Idle time before a replica is stopped (cost vs. cold starts). |
103
+ | `max_inputs` | `@modal.concurrent` | Concurrent requests per replica — tune to GPU memory. |
104
+ | `FAST_BOOT` | `modal_inference_phi.py` | `--enforce-eager` for faster cold starts vs. peak throughput. |
document_qa/deployment/_embeddings_app.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared building blocks for the Modal embedding endpoints.
2
+
3
+ ``modal_embeddings_en.py`` and ``modal_embeddings_multilang.py`` each define a tiny
4
+ ``EmbeddingModel`` class at module scope (Modal requires globally-defined classes
5
+ with stacked ``@app.cls`` / ``@modal.concurrent`` decorators) that delegates to the
6
+ helpers here. All the heavy lifting — the container image, model loading, pooling,
7
+ and the embedding request handler — lives in this module so it is written once.
8
+
9
+ The endpoint contract (consumed by ``document_qa.custom_embeddings.ModalEmbeddings``):
10
+
11
+ - **Method**: ``POST``
12
+ - **Auth**: ``x-api-key`` header, compared against the ``API_KEY`` secret.
13
+ - **Body**: form field ``text`` containing newline-separated strings.
14
+ - **Response**: JSON list of L2-normalised embedding vectors, one per input line.
15
+ """
16
+
17
+ import os
18
+
19
+ import modal
20
+ import torch
21
+ import torch.nn.functional as F
22
+ from fastapi import HTTPException, Request
23
+ from torch import Tensor
24
+
25
+ MINUTES = 60 # seconds
26
+ N_GPU = 1
27
+
28
+ # Shared container image for every embedding model.
29
+ image = (
30
+ modal.Image.debian_slim(python_version="3.11")
31
+ .pip_install(
32
+ "transformers",
33
+ "huggingface_hub[hf_transfer]==0.26.2",
34
+ "flashinfer-python==0.2.0.post2", # pinning, very unstable
35
+ "fastapi[standard]",
36
+ extra_index_url="https://flashinfer.ai/whl/cu124/torch2.5",
37
+ )
38
+ .env({"HF_HUB_ENABLE_HF_TRANSFER": "1"}) # faster model transfers
39
+ # Modal 1.0 no longer auto-mounts imported local modules; the wrapper scripts
40
+ # import this module by name, so it must be added explicitly. Kept last so it
41
+ # doesn't invalidate the (expensive) pip layer above on every code edit.
42
+ .add_local_python_source("_embeddings_app")
43
+ )
44
+
45
+ hf_cache_vol = modal.Volume.from_name("huggingface-cache", create_if_missing=True)
46
+ vllm_cache_vol = modal.Volume.from_name("vllm-cache", create_if_missing=True)
47
+
48
+
49
+ def cls_kwargs() -> dict:
50
+ """Common ``@app.cls`` configuration shared by every embedding endpoint."""
51
+ return dict(
52
+ image=image,
53
+ gpu=f"L40S:{N_GPU}",
54
+ # how long should we stay up with no requests?
55
+ scaledown_window=3 * MINUTES,
56
+ volumes={
57
+ "/root/.cache/huggingface": hf_cache_vol,
58
+ "/root/.cache/vllm": vllm_cache_vol,
59
+ },
60
+ secrets=[modal.Secret.from_name("document-qa-embedding-key")],
61
+ )
62
+
63
+
64
+ def average_pool(last_hidden_states: Tensor, attention_mask: Tensor) -> Tensor:
65
+ """Mean-pool token embeddings, ignoring padding positions."""
66
+ last_hidden = last_hidden_states.masked_fill(~attention_mask[..., None].bool(), 0.0)
67
+ return last_hidden.sum(dim=1) / attention_mask.sum(dim=1)[..., None]
68
+
69
+
70
+ def load_embedding_model(model_name: str, model_revision: str):
71
+ """Load a tokenizer + model onto the best available device, once per container.
72
+
73
+ Returns:
74
+ tuple: ``(tokenizer, model, device)`` with ``model`` already in eval mode.
75
+ """
76
+ # transformers is only available inside the Modal image, so import lazily.
77
+ from transformers import AutoModel, AutoTokenizer
78
+
79
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
80
+ print(f"Loading {model_name} on {device}...")
81
+ tokenizer = AutoTokenizer.from_pretrained(model_name, revision=model_revision)
82
+ model = AutoModel.from_pretrained(model_name, revision=model_revision).to(device)
83
+ model.eval()
84
+ print("Model loaded successfully.")
85
+ return tokenizer, model, device
86
+
87
+
88
+ def run_embed(tokenizer, model, device, request: Request, text: str):
89
+ """Authenticate, embed newline-separated ``text``, and return normalised vectors."""
90
+ api_key = request.headers.get("x-api-key")
91
+ if api_key != os.environ["API_KEY"]:
92
+ raise HTTPException(status_code=401, detail="Unauthorized")
93
+
94
+ texts = [t for t in text.split("\n") if t.strip()]
95
+ if not texts:
96
+ return []
97
+
98
+ print(f"Start embedding {len(texts)} texts")
99
+ try:
100
+ with torch.no_grad():
101
+ batch_dict = tokenizer(texts, padding=True, truncation=True, return_tensors="pt")
102
+ batch_dict = {k: v.to(device) for k, v in batch_dict.items()}
103
+
104
+ outputs = model(**batch_dict)
105
+ embeddings = average_pool(outputs.last_hidden_state, batch_dict["attention_mask"])
106
+ embeddings = F.normalize(embeddings, p=2, dim=1)
107
+ embeddings = embeddings.cpu().numpy().tolist()
108
+
109
+ print("Finished embedding texts.")
110
+ return embeddings
111
+
112
+ except RuntimeError as e:
113
+ print(f"Error during embedding: {str(e)}")
114
+ if "CUDA out of memory" in str(e):
115
+ print("CUDA OOM. Try reducing batch size or using a smaller model.")
116
+ raise
document_qa/deployment/modal_embeddings_en.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal deployment: English-only embeddings (``intfloat/e5-large-v2``).
2
+
3
+ Deploy with::
4
+
5
+ modal deploy document_qa/deployment/modal_embeddings_en.py
6
+
7
+ See ``document_qa/deployment/README.md`` for secrets, tuning, and how the
8
+ resulting URL maps to ``EMBEDS_URL`` in ``.env``. The shared logic lives in
9
+ ``_embeddings_app.py``.
10
+ """
11
+
12
+ from typing import Annotated
13
+
14
+ import modal
15
+ from fastapi import Form, Request
16
+
17
+ from _embeddings_app import cls_kwargs, load_embedding_model, run_embed
18
+
19
+ MODEL_NAME = "intfloat/e5-large-v2"
20
+ MODEL_REVISION = "756b8ddb6e4bda943d3b6f5d131355825efda70c"
21
+
22
+ app = modal.App("intfloat-e5-large-v2-embeddings")
23
+
24
+
25
+ @app.cls(**cls_kwargs())
26
+ @modal.concurrent(max_inputs=5) # requests per replica; tune carefully!
27
+ class EmbeddingModel:
28
+ @modal.enter()
29
+ def load_model(self):
30
+ self.tokenizer, self.model, self.device = load_embedding_model(MODEL_NAME, MODEL_REVISION)
31
+
32
+ @modal.fastapi_endpoint(method="POST")
33
+ def embed(self, request: Request, text: Annotated[str, Form()]):
34
+ return run_embed(self.tokenizer, self.model, self.device, request, text)
document_qa/deployment/modal_embeddings_multilang.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal deployment: multilingual embeddings (``intfloat/multilingual-e5-large-instruct``).
2
+
3
+ Deploy with::
4
+
5
+ modal deploy document_qa/deployment/modal_embeddings_multilang.py
6
+
7
+ See ``document_qa/deployment/README.md`` for secrets, tuning, and how the
8
+ resulting URL maps to ``EMBEDS_URL`` in ``.env``. The shared logic lives in
9
+ ``_embeddings_app.py``.
10
+ """
11
+
12
+ from typing import Annotated
13
+
14
+ import modal
15
+ from fastapi import Form, Request
16
+
17
+ from _embeddings_app import cls_kwargs, load_embedding_model, run_embed
18
+
19
+ MODEL_NAME = "intfloat/multilingual-e5-large-instruct"
20
+ MODEL_REVISION = "84344a23ee1820ac951bc365f1e91d094a911763"
21
+
22
+ app = modal.App("intfloat-multilingual-e5-large-instruct-embeddings")
23
+
24
+
25
+ @app.cls(**cls_kwargs())
26
+ @modal.concurrent(max_inputs=5) # requests per replica; tune carefully!
27
+ class EmbeddingModel:
28
+ @modal.enter()
29
+ def load_model(self):
30
+ self.tokenizer, self.model, self.device = load_embedding_model(MODEL_NAME, MODEL_REVISION)
31
+
32
+ @modal.fastapi_endpoint(method="POST")
33
+ def embed(self, request: Request, text: Annotated[str, Form()]):
34
+ return run_embed(self.tokenizer, self.model, self.device, request, text)
document_qa/deployment/modal_inference_phi.py CHANGED
@@ -3,7 +3,7 @@ import os
3
  import modal
4
 
5
  vllm_image = (
6
- modal.Image.debian_slim(python_version="3.10")
7
  .pip_install(
8
  "vllm",
9
  "huggingface_hub[hf_transfer]==0.26.2",
@@ -40,11 +40,9 @@ VLLM_PORT = 8000
40
  "/root/.cache/huggingface": hf_cache_vol,
41
  "/root/.cache/vllm": vllm_cache_vol,
42
  },
43
- secrets=[modal.Secret.from_name("document-qa-api-key")]
44
  )
45
- @modal.concurrent(
46
- max_inputs=5
47
- ) # how many requests can one replica handle? tune carefully!
48
  @modal.web_server(port=VLLM_PORT, startup_timeout=5 * MINUTES)
49
  def serve():
50
  import subprocess
@@ -73,4 +71,4 @@ def serve():
73
  # assume multiple GPUs are for splitting up large matrix multiplications
74
  cmd += ["--tensor-parallel-size", str(N_GPU)]
75
 
76
- subprocess.Popen(" ".join(cmd), shell=True)
 
3
  import modal
4
 
5
  vllm_image = (
6
+ modal.Image.debian_slim(python_version="3.11")
7
  .pip_install(
8
  "vllm",
9
  "huggingface_hub[hf_transfer]==0.26.2",
 
40
  "/root/.cache/huggingface": hf_cache_vol,
41
  "/root/.cache/vllm": vllm_cache_vol,
42
  },
43
+ secrets=[modal.Secret.from_name("document-qa-api-key")],
44
  )
45
+ @modal.concurrent(max_inputs=5) # how many requests can one replica handle? tune carefully!
 
 
46
  @modal.web_server(port=VLLM_PORT, startup_timeout=5 * MINUTES)
47
  def serve():
48
  import subprocess
 
71
  # assume multiple GPUs are for splitting up large matrix multiplications
72
  cmd += ["--tensor-parallel-size", str(N_GPU)]
73
 
74
+ subprocess.Popen(" ".join(cmd), shell=True)
document_qa/deployment/modal_inference_qwen.py CHANGED
@@ -22,7 +22,7 @@ hf_cache_vol = modal.Volume.from_name("huggingface-cache", create_if_missing=Tru
22
  vllm_cache_vol = modal.Volume.from_name("vllm-cache", create_if_missing=True)
23
 
24
 
25
- app = modal.App("gwen-0.6b-qa-vllm")
26
 
27
  N_GPU = 1
28
  MINUTES = 60 # seconds
@@ -39,11 +39,9 @@ VLLM_PORT = 8000
39
  "/root/.cache/huggingface": hf_cache_vol,
40
  "/root/.cache/vllm": vllm_cache_vol,
41
  },
42
- secrets=[modal.Secret.from_name("document-qa-api-key")]
43
  )
44
- @modal.concurrent(
45
- max_inputs=5
46
- ) # how many requests can one replica handle? tune carefully!
47
  @modal.web_server(port=VLLM_PORT, startup_timeout=5 * MINUTES)
48
  def serve():
49
  import subprocess
@@ -55,7 +53,8 @@ def serve():
55
  MODEL_NAME,
56
  "--revision",
57
  MODEL_REVISION,
58
- "--enable-reasoning",
 
59
  "--reasoning-parser",
60
  "deepseek_r1",
61
  "--max-model-len",
@@ -68,4 +67,4 @@ def serve():
68
  os.environ["API_KEY"],
69
  ]
70
 
71
- subprocess.Popen(" ".join(cmd), shell=True)
 
22
  vllm_cache_vol = modal.Volume.from_name("vllm-cache", create_if_missing=True)
23
 
24
 
25
+ app = modal.App("qwen-0.6b-qa-vllm")
26
 
27
  N_GPU = 1
28
  MINUTES = 60 # seconds
 
39
  "/root/.cache/huggingface": hf_cache_vol,
40
  "/root/.cache/vllm": vllm_cache_vol,
41
  },
42
+ secrets=[modal.Secret.from_name("document-qa-api-key")],
43
  )
44
+ @modal.concurrent(max_inputs=5) # how many requests can one replica handle? tune carefully!
 
 
45
  @modal.web_server(port=VLLM_PORT, startup_timeout=5 * MINUTES)
46
  def serve():
47
  import subprocess
 
53
  MODEL_NAME,
54
  "--revision",
55
  MODEL_REVISION,
56
+ # --reasoning-parser alone enables reasoning; the old --enable-reasoning
57
+ # flag was removed in recent vLLM releases.
58
  "--reasoning-parser",
59
  "deepseek_r1",
60
  "--max-model-len",
 
67
  os.environ["API_KEY"],
68
  ]
69
 
70
+ subprocess.Popen(" ".join(cmd), shell=True)
document_qa/document_qa_engine.py CHANGED
@@ -12,8 +12,7 @@ from typing import Union, Any, List
12
  import tiktoken
13
  from langchain.chains import create_extraction_chain
14
  from langchain.chains.combine_documents import create_stuff_documents_chain
15
- from langchain.chains.question_answering import stuff_prompt, refine_prompts, map_reduce_prompt, \
16
- map_rerank_prompt
17
  from langchain.prompts import SystemMessagePromptTemplate, HumanMessagePromptTemplate, ChatPromptTemplate
18
  from langchain.retrievers import MultiQueryRetriever
19
  from langchain.schema import Document
@@ -89,8 +88,8 @@ class TextMerger:
89
  current_texts = []
90
  current_coordinates = []
91
  for idx, passage in enumerate(passages):
92
- text = passage['text']
93
- coordinates = passage['coordinates']
94
  current_texts.append(text)
95
  current_coordinates.append(coordinates)
96
 
@@ -131,7 +130,7 @@ class TextMerger:
131
  "coordinates": coordinates,
132
  "type": "aggregated chunks",
133
  "section": "mixed",
134
- "subSection": "mixed"
135
  }
136
  )
137
 
@@ -139,14 +138,9 @@ class TextMerger:
139
 
140
 
141
  class BaseRetrieval:
142
- """Abstract base for retrieval backends.
143
- """
144
 
145
- def __init__(
146
- self,
147
- persist_directory: Path,
148
- embedding_function
149
- ):
150
  self.embedding_function = embedding_function
151
  self.persist_directory = persist_directory
152
 
@@ -156,13 +150,11 @@ class NER_Retrival(VectorStore):
156
  This class implement a retrieval based on NER models.
157
  This is an alternative retrieval to embeddings that relies on extracted entities.
158
  """
 
159
  pass
160
 
161
 
162
- engines = {
163
- 'chroma': ChromaAdvancedRetrieval,
164
- 'ner': NER_Retrival
165
- }
166
 
167
 
168
  class DataStorage:
@@ -184,10 +176,10 @@ class DataStorage:
184
  embeddings_map_to_md5 = {}
185
 
186
  def __init__(
187
- self,
188
- embedding_function,
189
- root_path: Path = None,
190
- engine=ChromaAdvancedRetrieval,
191
  ) -> None:
192
  self.root_path = root_path
193
  self.engine = engine
@@ -214,11 +206,10 @@ class DataStorage:
214
 
215
  for embedding_document_dir in embeddings_directories:
216
  self.embeddings_dict[embedding_document_dir.name] = self.engine(
217
- persist_directory=embedding_document_dir.path,
218
- embedding_function=self.embedding_function
219
  )
220
 
221
- filename_list = list(Path(embedding_document_dir).glob('*.storage_filename'))
222
  if filename_list:
223
  filenam = filename_list[0].name.replace(".storage_filename", "")
224
  self.embeddings_map_from_md5[embedding_document_dir.name] = filenam
@@ -248,18 +239,14 @@ class DataStorage:
248
  """
249
  if doc_id not in self.embeddings_dict.keys():
250
  self.embeddings_dict[doc_id] = self.engine.from_texts(
251
- texts,
252
- embedding=self.embedding_function,
253
- metadatas=metadatas,
254
- collection_name=doc_id)
255
  else:
256
  # Workaround Chroma (?) breaking change
257
  self.embeddings_dict[doc_id].delete_collection()
258
  self.embeddings_dict[doc_id] = self.engine.from_texts(
259
- texts,
260
- embedding=self.embedding_function,
261
- metadatas=metadatas,
262
- collection_name=doc_id)
263
 
264
  self.embeddings_root_path = None
265
 
@@ -287,23 +274,17 @@ class DocumentQAEngine:
287
  qa_chain_type = None
288
 
289
  default_prompts = {
290
- 'stuff': stuff_prompt,
291
- 'refine': refine_prompts,
292
  "map_reduce": map_reduce_prompt,
293
- "map_rerank": map_rerank_prompt
294
  }
295
 
296
- def __init__(self,
297
- llm,
298
- data_storage: DataStorage,
299
- grobid_url=None,
300
- memory=None,
301
- ping_grobid_server: bool = True
302
- ):
303
 
304
  self.llm = llm
305
  self.memory = memory
306
- self.chain = create_stuff_documents_chain(llm, self.default_prompts['stuff'].PROMPT)
307
  self.text_merger = TextMerger()
308
  self.data_storage = data_storage
309
 
@@ -311,13 +292,7 @@ class DocumentQAEngine:
311
  self.grobid_processor = GrobidProcessor(grobid_url, ping_server=ping_grobid_server)
312
 
313
  def query_document(
314
- self,
315
- query: str,
316
- doc_id,
317
- output_parser=None,
318
- context_size=4,
319
- extraction_schema=None,
320
- verbose=False
321
  ) -> tuple[Any, str, list]:
322
  """Ask a question and get an LLM-generated answer.
323
 
@@ -348,7 +323,7 @@ class DocumentQAEngine:
348
  print(query)
349
 
350
  response, coordinates = self._run_query(doc_id, query, context_size=context_size)
351
- response = response['output_text'] if 'output_text' in response else response
352
 
353
  if verbose:
354
  print(doc_id, "->", response)
@@ -410,10 +385,7 @@ class DocumentQAEngine:
410
  embedding metadata.
411
  """
412
  db = self.data_storage.embeddings_dict[doc_id]
413
- retriever = db.as_retriever(
414
- search_kwargs={"k": context_size},
415
- search_type="similarity_with_embeddings"
416
- )
417
  relevant_documents = retriever.invoke(query)
418
 
419
  return relevant_documents
@@ -440,10 +412,10 @@ class DocumentQAEngine:
440
  # )
441
  retriever = db.as_retriever(search_kwargs={"k": context_size}, search_type="similarity_with_embeddings")
442
  relevant_documents = retriever.invoke(query)
443
- relevant_document_coordinates = [doc.metadata['coordinates'].split(";") if 'coordinates' in doc.metadata else []
444
- for doc in
445
- relevant_documents]
446
- all_documents = db.get(include=['documents', 'metadatas', 'embeddings'])
447
  # all_documents_embeddings = all_documents["embeddings"]
448
  # query_embedding = db._embedding_function.embed_query(query)
449
 
@@ -453,16 +425,21 @@ class DocumentQAEngine:
453
 
454
  # distance_evaluator.evaluate_string_pairs(query=query_embedding, documents="")
455
 
456
- similarities = [doc.metadata['__similarity'] for doc in relevant_documents]
457
  min_similarity = min(similarities)
458
  mean_similarity = sum(similarities) / len(similarities)
459
  coefficient = min_similarity - mean_similarity
460
 
461
- return f"Coefficient: {coefficient}, (Min similarity {min_similarity}, Mean similarity: {mean_similarity})", relevant_document_coordinates
 
 
 
462
 
463
  def _parse_json(self, response, output_parser):
464
- system_message = "You are an useful assistant expert in materials science, physics, and chemistry " \
465
- "that can process text and transform it to JSON."
 
 
466
  human_message = """Transform the text between three double quotes in JSON.\n\n\n\n
467
  {format_instructions}\n\nText: \"\"\"{text}\"\"\""""
468
 
@@ -473,8 +450,7 @@ class DocumentQAEngine:
473
 
474
  results = self.llm(
475
  prompt_template.format_prompt(
476
- text=response,
477
- format_instructions=output_parser.get_format_instructions()
478
  ).to_messages()
479
  )
480
  parsed_output = output_parser.parse(results.content)
@@ -491,15 +467,15 @@ class DocumentQAEngine:
491
  retriever = db.as_retriever(search_kwargs={"k": context_size})
492
  relevant_documents = retriever.invoke(query)
493
  relevant_document_coordinates = [
494
- doc.metadata['coordinates'].split(";") if 'coordinates' in doc.metadata else []
495
- for doc in
496
- relevant_documents
497
  ]
498
  if self.memory and len(self.memory.buffer_as_messages) > 0:
499
  relevant_documents.append(
500
  Document(
501
  page_content="""Following, the previous question and answers. Use these information only when in the question there are unspecified references:\n{}\n\n""".format(
502
- self.memory.buffer_as_str))
 
 
503
  )
504
  return relevant_documents, relevant_document_coordinates
505
 
@@ -509,7 +485,7 @@ class DocumentQAEngine:
509
  """
510
  db = self.data_storage.embeddings_dict[doc_id]
511
  docs = db.get()
512
- return docs['documents']
513
 
514
  def _get_context_multiquery(self, doc_id, query, context_size=4):
515
  db = self.data_storage.embeddings_dict[doc_id].as_retriever(search_kwargs={"k": context_size})
@@ -547,8 +523,8 @@ class DocumentQAEngine:
547
  coordinates = True # if chunk_size == -1 else False
548
  structure = self.grobid_processor.process_structure(pdf_file_path, coordinates=coordinates)
549
 
550
- biblio = structure['biblio']
551
- biblio['filename'] = filename.replace(" ", "_")
552
 
553
  if verbose:
554
  print("Generating embeddings for filename: ", filename)
@@ -558,19 +534,19 @@ class DocumentQAEngine:
558
  ids = []
559
 
560
  if chunk_size > 0:
561
- new_passages = self.text_merger.merge_passages(structure['passages'], chunk_size=chunk_size)
562
  else:
563
- new_passages = structure['passages']
564
 
565
  for passage in new_passages:
566
  biblio_copy = copy.copy(biblio)
567
- if len(str.strip(passage['text'])) > 0:
568
- texts.append(passage['text'])
569
 
570
- biblio_copy['type'] = passage['type']
571
- biblio_copy['section'] = passage['section']
572
- biblio_copy['subSection'] = passage['subSection']
573
- biblio_copy['coordinates'] = passage['coordinates']
574
  metadatas.append(biblio_copy)
575
 
576
  # ids.append(passage['passage_id'])
@@ -579,13 +555,7 @@ class DocumentQAEngine:
579
 
580
  return texts, metadatas, ids
581
 
582
- def create_memory_embeddings(
583
- self,
584
- pdf_path,
585
- doc_id=None,
586
- chunk_size=500,
587
- perc_overlap=0.1
588
- ):
589
  """Parse a PDF and create an in-memory vector collection.
590
 
591
  This is the main entry-point for ingesting a new document. It
@@ -602,26 +572,17 @@ class DocumentQAEngine:
602
  Returns:
603
  str: The document ID.
604
  """
605
- texts, metadata, ids = self.get_text_from_document(
606
- pdf_path,
607
- chunk_size=chunk_size,
608
- perc_overlap=perc_overlap)
609
  if doc_id:
610
  hash = doc_id
611
  else:
612
- hash = metadata[0]['hash'] if len(metadata) > 0 and 'hash' in metadata[0] else ""
613
 
614
  self.data_storage.embed_document(hash, texts, metadata)
615
 
616
  return hash
617
 
618
- def create_embeddings(
619
- self,
620
- pdfs_dir_path: Path,
621
- chunk_size=500,
622
- perc_overlap=0.1,
623
- include_biblio=False
624
- ):
625
  """Batch-process a directory of PDFs and persist their embeddings.
626
 
627
  Walks *pdfs_dir_path*, processes each ``.pdf`` file through GROBID,
@@ -641,9 +602,7 @@ class DocumentQAEngine:
641
  continue
642
  input_files.append(os.path.join(root, file_))
643
 
644
- for input_file in tqdm(input_files, total=len(input_files), unit='document',
645
- desc="Grobid + embeddings processing"):
646
-
647
  md5 = self.calculate_md5(input_file)
648
  data_path = os.path.join(self.data_storage.embeddings_root_path, md5)
649
 
@@ -651,19 +610,15 @@ class DocumentQAEngine:
651
  print(data_path, "exists. Skipping it ")
652
  continue
653
  # include = ["biblio"] if include_biblio else []
654
- texts, metadata, ids = self.get_text_from_document(
655
- input_file,
656
- chunk_size=chunk_size,
657
- perc_overlap=perc_overlap)
658
- filename = metadata[0]['filename']
659
-
660
- vector_db_document = Chroma.from_texts(texts,
661
- metadatas=metadata,
662
- embedding=self.embedding_function,
663
- persist_directory=data_path)
664
  vector_db_document.persist()
665
 
666
- with open(os.path.join(data_path, filename + ".storage_filename"), 'w') as fo:
667
  fo.write("")
668
 
669
  @staticmethod
@@ -671,7 +626,8 @@ class DocumentQAEngine:
671
  """Return the uppercase hex MD5 digest of *input_file*."""
672
 
673
  import hashlib
 
674
  md5_hash = hashlib.md5()
675
- with open(input_file, 'rb') as fi:
676
  md5_hash.update(fi.read())
677
  return md5_hash.hexdigest().upper()
 
12
  import tiktoken
13
  from langchain.chains import create_extraction_chain
14
  from langchain.chains.combine_documents import create_stuff_documents_chain
15
+ from langchain.chains.question_answering import stuff_prompt, refine_prompts, map_reduce_prompt, map_rerank_prompt
 
16
  from langchain.prompts import SystemMessagePromptTemplate, HumanMessagePromptTemplate, ChatPromptTemplate
17
  from langchain.retrievers import MultiQueryRetriever
18
  from langchain.schema import Document
 
88
  current_texts = []
89
  current_coordinates = []
90
  for idx, passage in enumerate(passages):
91
+ text = passage["text"]
92
+ coordinates = passage["coordinates"]
93
  current_texts.append(text)
94
  current_coordinates.append(coordinates)
95
 
 
130
  "coordinates": coordinates,
131
  "type": "aggregated chunks",
132
  "section": "mixed",
133
+ "subSection": "mixed",
134
  }
135
  )
136
 
 
138
 
139
 
140
  class BaseRetrieval:
141
+ """Abstract base for retrieval backends."""
 
142
 
143
+ def __init__(self, persist_directory: Path, embedding_function):
 
 
 
 
144
  self.embedding_function = embedding_function
145
  self.persist_directory = persist_directory
146
 
 
150
  This class implement a retrieval based on NER models.
151
  This is an alternative retrieval to embeddings that relies on extracted entities.
152
  """
153
+
154
  pass
155
 
156
 
157
+ engines = {"chroma": ChromaAdvancedRetrieval, "ner": NER_Retrival}
 
 
 
158
 
159
 
160
  class DataStorage:
 
176
  embeddings_map_to_md5 = {}
177
 
178
  def __init__(
179
+ self,
180
+ embedding_function,
181
+ root_path: Path = None,
182
+ engine=ChromaAdvancedRetrieval,
183
  ) -> None:
184
  self.root_path = root_path
185
  self.engine = engine
 
206
 
207
  for embedding_document_dir in embeddings_directories:
208
  self.embeddings_dict[embedding_document_dir.name] = self.engine(
209
+ persist_directory=embedding_document_dir.path, embedding_function=self.embedding_function
 
210
  )
211
 
212
+ filename_list = list(Path(embedding_document_dir).glob("*.storage_filename"))
213
  if filename_list:
214
  filenam = filename_list[0].name.replace(".storage_filename", "")
215
  self.embeddings_map_from_md5[embedding_document_dir.name] = filenam
 
239
  """
240
  if doc_id not in self.embeddings_dict.keys():
241
  self.embeddings_dict[doc_id] = self.engine.from_texts(
242
+ texts, embedding=self.embedding_function, metadatas=metadatas, collection_name=doc_id
243
+ )
 
 
244
  else:
245
  # Workaround Chroma (?) breaking change
246
  self.embeddings_dict[doc_id].delete_collection()
247
  self.embeddings_dict[doc_id] = self.engine.from_texts(
248
+ texts, embedding=self.embedding_function, metadatas=metadatas, collection_name=doc_id
249
+ )
 
 
250
 
251
  self.embeddings_root_path = None
252
 
 
274
  qa_chain_type = None
275
 
276
  default_prompts = {
277
+ "stuff": stuff_prompt,
278
+ "refine": refine_prompts,
279
  "map_reduce": map_reduce_prompt,
280
+ "map_rerank": map_rerank_prompt,
281
  }
282
 
283
+ def __init__(self, llm, data_storage: DataStorage, grobid_url=None, memory=None, ping_grobid_server: bool = True):
 
 
 
 
 
 
284
 
285
  self.llm = llm
286
  self.memory = memory
287
+ self.chain = create_stuff_documents_chain(llm, self.default_prompts["stuff"].PROMPT)
288
  self.text_merger = TextMerger()
289
  self.data_storage = data_storage
290
 
 
292
  self.grobid_processor = GrobidProcessor(grobid_url, ping_server=ping_grobid_server)
293
 
294
  def query_document(
295
+ self, query: str, doc_id, output_parser=None, context_size=4, extraction_schema=None, verbose=False
 
 
 
 
 
 
296
  ) -> tuple[Any, str, list]:
297
  """Ask a question and get an LLM-generated answer.
298
 
 
323
  print(query)
324
 
325
  response, coordinates = self._run_query(doc_id, query, context_size=context_size)
326
+ response = response["output_text"] if "output_text" in response else response
327
 
328
  if verbose:
329
  print(doc_id, "->", response)
 
385
  embedding metadata.
386
  """
387
  db = self.data_storage.embeddings_dict[doc_id]
388
+ retriever = db.as_retriever(search_kwargs={"k": context_size}, search_type="similarity_with_embeddings")
 
 
 
389
  relevant_documents = retriever.invoke(query)
390
 
391
  return relevant_documents
 
412
  # )
413
  retriever = db.as_retriever(search_kwargs={"k": context_size}, search_type="similarity_with_embeddings")
414
  relevant_documents = retriever.invoke(query)
415
+ relevant_document_coordinates = [
416
+ doc.metadata["coordinates"].split(";") if "coordinates" in doc.metadata else [] for doc in relevant_documents
417
+ ]
418
+ all_documents = db.get(include=["documents", "metadatas", "embeddings"])
419
  # all_documents_embeddings = all_documents["embeddings"]
420
  # query_embedding = db._embedding_function.embed_query(query)
421
 
 
425
 
426
  # distance_evaluator.evaluate_string_pairs(query=query_embedding, documents="")
427
 
428
+ similarities = [doc.metadata["__similarity"] for doc in relevant_documents]
429
  min_similarity = min(similarities)
430
  mean_similarity = sum(similarities) / len(similarities)
431
  coefficient = min_similarity - mean_similarity
432
 
433
+ return (
434
+ f"Coefficient: {coefficient}, (Min similarity {min_similarity}, Mean similarity: {mean_similarity})",
435
+ relevant_document_coordinates,
436
+ )
437
 
438
  def _parse_json(self, response, output_parser):
439
+ system_message = (
440
+ "You are an useful assistant expert in materials science, physics, and chemistry "
441
+ "that can process text and transform it to JSON."
442
+ )
443
  human_message = """Transform the text between three double quotes in JSON.\n\n\n\n
444
  {format_instructions}\n\nText: \"\"\"{text}\"\"\""""
445
 
 
450
 
451
  results = self.llm(
452
  prompt_template.format_prompt(
453
+ text=response, format_instructions=output_parser.get_format_instructions()
 
454
  ).to_messages()
455
  )
456
  parsed_output = output_parser.parse(results.content)
 
467
  retriever = db.as_retriever(search_kwargs={"k": context_size})
468
  relevant_documents = retriever.invoke(query)
469
  relevant_document_coordinates = [
470
+ doc.metadata["coordinates"].split(";") if "coordinates" in doc.metadata else [] for doc in relevant_documents
 
 
471
  ]
472
  if self.memory and len(self.memory.buffer_as_messages) > 0:
473
  relevant_documents.append(
474
  Document(
475
  page_content="""Following, the previous question and answers. Use these information only when in the question there are unspecified references:\n{}\n\n""".format(
476
+ self.memory.buffer_as_str
477
+ )
478
+ )
479
  )
480
  return relevant_documents, relevant_document_coordinates
481
 
 
485
  """
486
  db = self.data_storage.embeddings_dict[doc_id]
487
  docs = db.get()
488
+ return docs["documents"]
489
 
490
  def _get_context_multiquery(self, doc_id, query, context_size=4):
491
  db = self.data_storage.embeddings_dict[doc_id].as_retriever(search_kwargs={"k": context_size})
 
523
  coordinates = True # if chunk_size == -1 else False
524
  structure = self.grobid_processor.process_structure(pdf_file_path, coordinates=coordinates)
525
 
526
+ biblio = structure["biblio"]
527
+ biblio["filename"] = filename.replace(" ", "_")
528
 
529
  if verbose:
530
  print("Generating embeddings for filename: ", filename)
 
534
  ids = []
535
 
536
  if chunk_size > 0:
537
+ new_passages = self.text_merger.merge_passages(structure["passages"], chunk_size=chunk_size)
538
  else:
539
+ new_passages = structure["passages"]
540
 
541
  for passage in new_passages:
542
  biblio_copy = copy.copy(biblio)
543
+ if len(str.strip(passage["text"])) > 0:
544
+ texts.append(passage["text"])
545
 
546
+ biblio_copy["type"] = passage["type"]
547
+ biblio_copy["section"] = passage["section"]
548
+ biblio_copy["subSection"] = passage["subSection"]
549
+ biblio_copy["coordinates"] = passage["coordinates"]
550
  metadatas.append(biblio_copy)
551
 
552
  # ids.append(passage['passage_id'])
 
555
 
556
  return texts, metadatas, ids
557
 
558
+ def create_memory_embeddings(self, pdf_path, doc_id=None, chunk_size=500, perc_overlap=0.1):
 
 
 
 
 
 
559
  """Parse a PDF and create an in-memory vector collection.
560
 
561
  This is the main entry-point for ingesting a new document. It
 
572
  Returns:
573
  str: The document ID.
574
  """
575
+ texts, metadata, ids = self.get_text_from_document(pdf_path, chunk_size=chunk_size, perc_overlap=perc_overlap)
 
 
 
576
  if doc_id:
577
  hash = doc_id
578
  else:
579
+ hash = metadata[0]["hash"] if len(metadata) > 0 and "hash" in metadata[0] else ""
580
 
581
  self.data_storage.embed_document(hash, texts, metadata)
582
 
583
  return hash
584
 
585
+ def create_embeddings(self, pdfs_dir_path: Path, chunk_size=500, perc_overlap=0.1, include_biblio=False):
 
 
 
 
 
 
586
  """Batch-process a directory of PDFs and persist their embeddings.
587
 
588
  Walks *pdfs_dir_path*, processes each ``.pdf`` file through GROBID,
 
602
  continue
603
  input_files.append(os.path.join(root, file_))
604
 
605
+ for input_file in tqdm(input_files, total=len(input_files), unit="document", desc="Grobid + embeddings processing"):
 
 
606
  md5 = self.calculate_md5(input_file)
607
  data_path = os.path.join(self.data_storage.embeddings_root_path, md5)
608
 
 
610
  print(data_path, "exists. Skipping it ")
611
  continue
612
  # include = ["biblio"] if include_biblio else []
613
+ texts, metadata, ids = self.get_text_from_document(input_file, chunk_size=chunk_size, perc_overlap=perc_overlap)
614
+ filename = metadata[0]["filename"]
615
+
616
+ vector_db_document = Chroma.from_texts(
617
+ texts, metadatas=metadata, embedding=self.embedding_function, persist_directory=data_path
618
+ )
 
 
 
 
619
  vector_db_document.persist()
620
 
621
+ with open(os.path.join(data_path, filename + ".storage_filename"), "w") as fo:
622
  fo.write("")
623
 
624
  @staticmethod
 
626
  """Return the uppercase hex MD5 digest of *input_file*."""
627
 
628
  import hashlib
629
+
630
  md5_hash = hashlib.md5()
631
+ with open(input_file, "rb") as fi:
632
  md5_hash.update(fi.read())
633
  return md5_hash.hexdigest().upper()
document_qa/grobid_processors.py CHANGED
@@ -36,11 +36,11 @@ class GrobidServiceError(RuntimeError):
36
  def get_span_start(type, title=None):
37
  """Return an opening ``<span>`` tag for an annotation of the given *type*."""
38
  title_ = ' title="' + title + '"' if title is not None else ""
39
- return '<span class="label ' + type + '"' + title_ + '>'
40
 
41
 
42
  def get_span_end():
43
- return '</span>'
44
 
45
 
46
  def get_rs_start(type):
@@ -48,11 +48,11 @@ def get_rs_start(type):
48
 
49
 
50
  def get_rs_end():
51
- return '</rs>'
52
 
53
 
54
  def has_space_between_value_and_unit(quantity):
55
- return quantity['offsetEnd'] < quantity['rawUnit']['offsetStart']
56
 
57
 
58
  def decorate_text_with_annotations(text, spans, tag="span"):
@@ -70,27 +70,27 @@ def decorate_text_with_annotations(text, spans, tag="span"):
70
  Returns:
71
  str: The text with inline annotation markup.
72
  """
73
- sorted_spans = list(sorted(spans, key=lambda item: item['offset_start']))
74
  annotated_text = ""
75
  start = 0
76
  for span in sorted_spans:
77
- type = span['type'].replace("<", "").replace(">", "")
78
- if 'unit_type' in span and span['unit_type'] is not None:
79
- type = span['unit_type'].replace(" ", "_")
80
- annotated_text += escape(text[start: span['offset_start']])
81
- title = span['quantified'] if 'quantified' in span else None
82
  annotated_text += get_span_start(type, title) if tag == "span" else get_rs_start(type)
83
- annotated_text += escape(text[span['offset_start']: span['offset_end']])
84
  annotated_text += get_span_end() if tag == "span" else get_rs_end()
85
 
86
- start = span['offset_end']
87
- annotated_text += escape(text[start: len(text)])
88
  return annotated_text
89
 
90
 
91
  def get_parsed_value_type(quantity):
92
- if 'parsedValue' in quantity and 'structure' in quantity['parsedValue']:
93
- return quantity['parsedValue']['structure']['type']
94
 
95
 
96
  class BaseProcessor(object):
@@ -101,9 +101,7 @@ class BaseProcessor(object):
101
  inherit :meth:`post_process` from here.
102
  """
103
 
104
- patterns = [
105
- r'\d+e\d+'
106
- ]
107
 
108
  def post_process(self, text):
109
  """Clean encoding artefacts and normalise special characters.
@@ -114,16 +112,16 @@ class BaseProcessor(object):
114
  Returns:
115
  str: Cleaned text.
116
  """
117
- output = text.replace('À', '-')
118
- output = output.replace('¼', '=')
119
- output = output.replace('þ', '+')
120
- output = output.replace('Â', 'x')
121
- output = output.replace('$', '~')
122
- output = output.replace('', '-')
123
- output = output.replace('', '-')
124
 
125
  for pattern in self.patterns:
126
- output = re.sub(pattern, lambda match: match.group().replace('e', '-'), output)
127
 
128
  return output
129
 
@@ -154,7 +152,7 @@ class GrobidProcessor(BaseProcessor):
154
  coordinates=["p", "title", "persName"],
155
  sleep_time=5,
156
  timeout=60,
157
- check_server=ping_server
158
  )
159
  self.grobid_client = grobid_client
160
 
@@ -178,15 +176,17 @@ class GrobidProcessor(BaseProcessor):
178
  Returns ``None`` if GROBID returns a non-200 status.
179
  """
180
  try:
181
- pdf_file, status, text = self.grobid_client.process_pdf("processFulltextDocument",
182
- input_path,
183
- consolidate_header=True,
184
- consolidate_citations=False,
185
- segment_sentences=False,
186
- tei_coordinates=coordinates,
187
- include_raw_citations=False,
188
- include_raw_affiliations=False,
189
- generateIDs=True)
 
 
190
  except requests.exceptions.RequestException as exc:
191
  # Transport-level failure (connection refused, timeout, …).
192
  # Local/usage errors (bad path, parsing bugs) are intentionally
@@ -205,10 +205,7 @@ class GrobidProcessor(BaseProcessor):
205
 
206
  # Grobid can answer 200 with an empty body (e.g. it gave up on the PDF).
207
  if not text or not text.strip():
208
- raise GrobidServiceError(
209
- "Grobid returned an empty response.",
210
- status_code=status
211
- )
212
 
213
  # A truncated/corrupted TEI payload makes the XML parser blow up; map
214
  # that to a clear service error instead of an opaque parsing traceback.
@@ -217,29 +214,23 @@ class GrobidProcessor(BaseProcessor):
217
  except GrobidServiceError:
218
  raise
219
  except Exception as exc:
220
- raise GrobidServiceError(
221
- "Grobid returned a malformed or truncated response.",
222
- status_code=status
223
- ) from exc
224
 
225
- document_object['filename'] = Path(pdf_file).stem.replace(".tei", "")
226
 
227
  # Well-formed XML can still carry no usable text (e.g. an image-only or
228
  # truncated PDF). Nothing to embed downstream, so fail loudly here.
229
- if not any(passage.get('text', '').strip() for passage in document_object.get('passages', [])):
230
- raise GrobidServiceError(
231
- "Grobid returned a document with no extractable text.",
232
- status_code=status
233
- )
234
 
235
  return document_object
236
 
237
  def process_single(self, input_file):
238
  doc = self.process_structure(input_file)
239
 
240
- for paragraph in doc['passages']:
241
- entities = self.process_single_text(paragraph['text'])
242
- paragraph['spans'] = entities
243
 
244
  return doc
245
 
@@ -264,7 +255,7 @@ class GrobidProcessor(BaseProcessor):
264
  "doi": doc_biblio.header.doi if doc_biblio.header.doi is not None else "",
265
  "authors": ", ".join([author.full_name for author in doc_biblio.header.authors]),
266
  "title": doc_biblio.header.title,
267
- "hash": doc_biblio.pdf_md5
268
  }
269
  try:
270
  year = dateparser.parse(doc_biblio.header.date).year
@@ -272,12 +263,12 @@ class GrobidProcessor(BaseProcessor):
272
  except Exception:
273
  pass
274
 
275
- output_data['biblio'] = biblio
276
  passages = []
277
- output_data['passages'] = passages
278
  passage_type = "paragraph"
279
 
280
- soup = BeautifulSoup(text, 'xml')
281
  blocks_header = get_xml_nodes_header(soup, use_paragraphs=True)
282
 
283
  # passages.append({
@@ -290,99 +281,132 @@ class GrobidProcessor(BaseProcessor):
290
  # blocks_header['authors']])
291
  # })
292
 
293
- passages.append({
294
- "text": self.post_process(" ".join([node.text for node in blocks_header['title']])),
295
- "type": passage_type,
296
- "section": "<header>",
297
- "subSection": "<title>",
298
- "passage_id": "htitle",
299
- "coordinates": ";".join([node['coords'] if coordinates and node.has_attr('coords') else "" for node in
300
- blocks_header['title']])
301
- })
302
-
303
- passages.append({
304
- "text": self.post_process(
305
- ''.join(node.text for node in blocks_header['abstract'] for text in node.find_all(text=True) if
306
- text.parent.name != "ref" or (
307
- text.parent.name == "ref" and text.parent.attrs[
308
- 'type'] != 'bibr'))),
309
- "type": passage_type,
310
- "section": "<header>",
311
- "subSection": "<abstract>",
312
- "passage_id": "habstract",
313
- "coordinates": ";".join([node['coords'] if coordinates and node.has_attr('coords') else "" for node in
314
- blocks_header['abstract']])
315
- })
 
 
 
 
 
 
 
 
 
316
 
317
  text_blocks_body = get_xml_nodes_body(soup, verbose=False, use_paragraphs=True)
318
  text_blocks_body.extend(get_xml_nodes_back(soup, verbose=False, use_paragraphs=True))
319
 
320
  use_paragraphs = True
321
  if not use_paragraphs:
322
- passages.extend([
323
- {
324
- "text": self.post_process(''.join(text for text in sentence.find_all(text=True) if
325
- text.parent.name != "ref" or (
326
- text.parent.name == "ref" and text.parent.attrs[
327
- 'type'] != 'bibr'))),
328
- "type": passage_type,
329
- "section": "<body>",
330
- "subSection": "<paragraph>",
331
- "passage_id": str(paragraph_id),
332
- "coordinates": paragraph['coords'] if coordinates and sentence.has_attr('coords') else ""
333
- }
334
- for paragraph_id, paragraph in enumerate(text_blocks_body) for
335
- sentence_id, sentence in enumerate(paragraph)
336
- ])
 
 
 
 
 
 
337
  else:
338
- passages.extend([
339
- {
340
- "text": self.post_process(''.join(text for text in paragraph.find_all(text=True) if
341
- text.parent.name != "ref" or (
342
- text.parent.name == "ref" and text.parent.attrs[
343
- 'type'] != 'bibr'))),
344
- "type": passage_type,
345
- "section": "<body>",
346
- "subSection": "<paragraph>",
347
- "passage_id": str(paragraph_id),
348
- "coordinates": paragraph['coords'] if coordinates and paragraph.has_attr('coords') else ""
349
- }
350
- for paragraph_id, paragraph in enumerate(text_blocks_body)
351
- ])
 
 
 
 
 
 
352
 
353
  text_blocks_figures = get_xml_nodes_figures(soup, verbose=False)
354
 
355
  if not use_paragraphs:
356
- passages.extend([
357
- {
358
- "text": self.post_process(''.join(text for text in sentence.find_all(text=True) if
359
- text.parent.name != "ref" or (
360
- text.parent.name == "ref" and text.parent.attrs[
361
- 'type'] != 'bibr'))),
362
- "type": passage_type,
363
- "section": "<body>",
364
- "subSection": "<figure>",
365
- "passage_id": str(paragraph_id) + str(sentence_id),
366
- "coordinates": sentence['coords'] if coordinates and 'coords' in sentence else ""
367
- }
368
- for paragraph_id, paragraph in enumerate(text_blocks_figures) for
369
- sentence_id, sentence in enumerate(paragraph)
370
- ])
 
 
 
 
 
 
371
  else:
372
- passages.extend([
373
- {
374
- "text": self.post_process(''.join(text for text in paragraph.find_all(text=True) if
375
- text.parent.name != "ref" or (
376
- text.parent.name == "ref" and text.parent.attrs[
377
- 'type'] != 'bibr'))),
378
- "type": passage_type,
379
- "section": "<body>",
380
- "subSection": "<figure>",
381
- "passage_id": str(paragraph_id),
382
- "coordinates": paragraph['coords'] if coordinates and paragraph.has_attr('coords') else ""
383
- }
384
- for paragraph_id, paragraph in enumerate(text_blocks_figures)
385
- ])
 
 
 
 
 
 
386
 
387
  return output_data
388
 
@@ -418,26 +442,26 @@ class GrobidQuantitiesProcessor(BaseProcessor):
418
 
419
  spans = []
420
 
421
- if 'measurements' in result:
422
  found_measurements = self.parse_measurements_output(result)
423
 
424
  for m in found_measurements:
425
  item = {
426
- "text": text[m['offset_start']:m['offset_end']],
427
- 'offset_start': m['offset_start'],
428
- 'offset_end': m['offset_end']
429
  }
430
 
431
- if 'raw' in m and m['raw'] != item['text']:
432
- item['text'] = m['raw']
433
 
434
- if 'quantified_substance' in m:
435
- item['quantified'] = m['quantified_substance']
436
 
437
- if 'type' in m:
438
- item["unit_type"] = m['type']
439
 
440
- item['type'] = 'property'
441
  # if 'raw_value' in m:
442
  # item['raw_value'] = m['raw_value']
443
 
@@ -449,21 +473,21 @@ class GrobidQuantitiesProcessor(BaseProcessor):
449
  def parse_measurements_output(result):
450
  measurements_output = []
451
 
452
- for measurement in result['measurements']:
453
- type = measurement['type']
454
  measurement_output_object = {}
455
  quantity_type = None
456
  has_unit = False
457
  parsed_value_type = None
458
 
459
- if 'quantified' in measurement:
460
- if 'normalizedName' in measurement['quantified']:
461
- quantified_substance = measurement['quantified']['normalizedName']
462
  measurement_output_object["quantified_substance"] = quantified_substance
463
 
464
- if 'measurementOffsets' in measurement:
465
- measurement_output_object["offset_start"] = measurement["measurementOffsets"]['start']
466
- measurement_output_object["offset_end"] = measurement["measurementOffsets"]['end']
467
  else:
468
  # If there are no offsets we skip the measurement
469
  continue
@@ -471,66 +495,66 @@ class GrobidQuantitiesProcessor(BaseProcessor):
471
  # if 'measurementRaw' in measurement:
472
  # measurement_output_object['raw_value'] = measurement['measurementRaw']
473
 
474
- if type == 'value':
475
- quantity = measurement['quantity']
476
 
477
  parsed_value = GrobidQuantitiesProcessor.get_parsed(quantity)
478
  if parsed_value:
479
- measurement_output_object['parsed'] = parsed_value
480
 
481
  normalized_value = GrobidQuantitiesProcessor.get_normalized(quantity)
482
  if normalized_value:
483
- measurement_output_object['normalized'] = normalized_value
484
 
485
  raw_value = GrobidQuantitiesProcessor.get_raw(quantity)
486
  if raw_value:
487
- measurement_output_object['raw'] = raw_value
488
 
489
- if 'type' in quantity:
490
- quantity_type = quantity['type']
491
 
492
- if 'rawUnit' in quantity:
493
  has_unit = True
494
 
495
  parsed_value_type = get_parsed_value_type(quantity)
496
 
497
- elif type == 'interval':
498
- if 'quantityMost' in measurement:
499
- quantityMost = measurement['quantityMost']
500
- if 'type' in quantityMost:
501
- quantity_type = quantityMost['type']
502
 
503
- if 'rawUnit' in quantityMost:
504
  has_unit = True
505
 
506
  parsed_value_type = get_parsed_value_type(quantityMost)
507
 
508
- if 'quantityLeast' in measurement:
509
- quantityLeast = measurement['quantityLeast']
510
 
511
- if 'type' in quantityLeast:
512
- quantity_type = quantityLeast['type']
513
 
514
- if 'rawUnit' in quantityLeast:
515
  has_unit = True
516
 
517
  parsed_value_type = get_parsed_value_type(quantityLeast)
518
 
519
- elif type == 'listc':
520
- quantities = measurement['quantities']
521
 
522
- if 'type' in quantities[0]:
523
- quantity_type = quantities[0]['type']
524
 
525
- if 'rawUnit' in quantities[0]:
526
  has_unit = True
527
 
528
  parsed_value_type = get_parsed_value_type(quantities[0])
529
 
530
  if quantity_type is not None or has_unit:
531
- measurement_output_object['type'] = quantity_type
532
 
533
- if parsed_value_type is None or parsed_value_type not in ['ALPHABETIC', 'TIME']:
534
  measurements_output.append(measurement_output_object)
535
 
536
  return measurements_output
@@ -538,10 +562,10 @@ class GrobidQuantitiesProcessor(BaseProcessor):
538
  @staticmethod
539
  def get_parsed(quantity):
540
  parsed_value = parsed_unit = None
541
- if 'parsedValue' in quantity and 'parsed' in quantity['parsedValue']:
542
- parsed_value = quantity['parsedValue']['parsed']
543
- if 'parsedUnit' in quantity and 'name' in quantity['parsedUnit']:
544
- parsed_unit = quantity['parsedUnit']['name']
545
 
546
  if parsed_value and parsed_unit:
547
  if has_space_between_value_and_unit(quantity):
@@ -552,10 +576,10 @@ class GrobidQuantitiesProcessor(BaseProcessor):
552
  @staticmethod
553
  def get_normalized(quantity):
554
  normalized_value = normalized_unit = None
555
- if 'normalizedQuantity' in quantity:
556
- normalized_value = quantity['normalizedQuantity']
557
- if 'normalizedUnit' in quantity and 'name' in quantity['normalizedUnit']:
558
- normalized_unit = quantity['normalizedUnit']['name']
559
 
560
  if normalized_value and normalized_unit:
561
  if has_space_between_value_and_unit(quantity):
@@ -566,10 +590,10 @@ class GrobidQuantitiesProcessor(BaseProcessor):
566
  @staticmethod
567
  def get_raw(quantity):
568
  raw_value = raw_unit = None
569
- if 'rawValue' in quantity:
570
- raw_value = quantity['rawValue']
571
- if 'rawUnit' in quantity and 'name' in quantity['rawUnit']:
572
- raw_unit = quantity['rawUnit']['name']
573
 
574
  if raw_value and raw_unit:
575
  if has_space_between_value_and_unit(quantity):
@@ -603,28 +627,27 @@ class GrobidMaterialsProcessor(BaseProcessor):
603
  ``type`` (``"material"``), and optional ``formula`` keys.
604
  """
605
  preprocessed_text = text.strip()
606
- status, result = self.grobid_superconductors_client.process_text(preprocessed_text,
607
- "processText_disable_linking")
608
 
609
  if status != 200:
610
  result = {}
611
 
612
  spans = []
613
 
614
- if 'passages' in result:
615
  materials = self.parse_superconductors_output(result, preprocessed_text)
616
 
617
  for m in materials:
618
- item = {"text": preprocessed_text[m['offset_start']:m['offset_end']]}
619
 
620
- item['offset_start'] = m['offset_start']
621
- item['offset_end'] = m['offset_end']
622
 
623
- if 'formula' in m:
624
- item["formula"] = m['formula']
625
 
626
- item['type'] = 'material'
627
- item['raw_value'] = m['text']
628
 
629
  spans.append(item)
630
 
@@ -640,13 +663,13 @@ class GrobidMaterialsProcessor(BaseProcessor):
640
  for position_material in result:
641
  compositions = []
642
  for material in position_material:
643
- if 'resolvedFormulas' in material:
644
- for resolved_formula in material['resolvedFormulas']:
645
- if 'formulaComposition' in resolved_formula:
646
- compositions.append(resolved_formula['formulaComposition'])
647
- elif 'formula' in material:
648
- if 'formulaComposition' in material['formula']:
649
- compositions.append(material['formula']['formulaComposition'])
650
  results.append(compositions)
651
 
652
  return results
@@ -664,32 +687,32 @@ class GrobidMaterialsProcessor(BaseProcessor):
664
  def output_info(self, result):
665
  compositions = []
666
  for material in result:
667
- if 'resolvedFormulas' in material:
668
- for resolved_formula in material['resolvedFormulas']:
669
- if 'formulaComposition' in resolved_formula:
670
- compositions.append(resolved_formula['formulaComposition'])
671
- elif 'formula' in material:
672
- if 'formulaComposition' in material['formula']:
673
- compositions.append(material['formula']['formulaComposition'])
674
- if 'name' in material:
675
- compositions.append(material['name'])
676
  return compositions
677
 
678
  @staticmethod
679
  def parse_superconductors_output(result, original_text):
680
  materials = []
681
 
682
- for passage in result['passages']:
683
- sentence_offset = original_text.index(passage['text'])
684
- if 'spans' in passage:
685
- spans = passage['spans']
686
- for material_span in filter(lambda s: s['type'] == '<material>', spans):
687
- text_ = material_span['text']
688
 
689
  base_material_information = {
690
  "text": text_,
691
- "offset_start": sentence_offset + material_span['offset_start'],
692
- 'offset_end': sentence_offset + material_span['offset_end']
693
  }
694
 
695
  materials.append(base_material_information)
@@ -765,13 +788,13 @@ class GrobidAggregationProcessor(GrobidQuantitiesProcessor, GrobidMaterialsProce
765
 
766
  item = {"page": box[0], "x": box[1], "y": box[2], "width": box[3], "height": box[4]}
767
  if color:
768
- item['color'] = color
769
 
770
  if type:
771
- item['type'] = type
772
 
773
  if border:
774
- item['border'] = border
775
 
776
  return item
777
 
@@ -790,7 +813,7 @@ class GrobidAggregationProcessor(GrobidQuantitiesProcessor, GrobidMaterialsProce
790
  list[dict]: Pruned, non-overlapping spans sorted by offset.
791
  """
792
  # Sorting by offsets
793
- sorted_entities = sorted(entities, key=lambda d: d['offset_start'])
794
 
795
  if len(entities) <= 1:
796
  return sorted_entities
@@ -806,96 +829,104 @@ class GrobidAggregationProcessor(GrobidQuantitiesProcessor, GrobidMaterialsProce
806
  previous = current
807
  continue
808
 
809
- if previous['offset_start'] < current['offset_start'] \
810
- and previous['offset_end'] < current['offset_end'] \
811
- and (previous['offset_end'] < current['offset_start'] \
812
- and not (previous['text'] == "-" and current['text'][0].isdigit())):
 
 
 
 
813
  previous = current
814
  continue
815
 
816
- if previous['offset_end'] < current['offset_end']:
817
- if current['type'] == previous['type']:
818
  # Type is the same
819
- if current['offset_start'] == previous['offset_end']:
820
- if current['type'] == 'property':
821
- if current['text'].startswith("."):
822
  print(
823
- f"Merging. {current['text']} <{current['type']}> with {previous['text']} <{previous['type']}>")
 
824
  # current entity starts with a ".", suspiciously look like a truncated value
825
  to_be_removed.append(previous)
826
- current['text'] = previous['text'] + current['text']
827
- current['raw_value'] = current['text']
828
- current['offset_start'] = previous['offset_start']
829
- elif previous['text'].endswith(".") and current['text'][0].isdigit():
830
  print(
831
- f"Merging. {current['text']} <{current['type']}> with {previous['text']} <{previous['type']}>")
 
832
  # previous entity ends with ".", current entity starts with a number
833
  to_be_removed.append(previous)
834
- current['text'] = previous['text'] + current['text']
835
- current['raw_value'] = current['text']
836
- current['offset_start'] = previous['offset_start']
837
- elif previous['text'].startswith("-"):
838
  print(
839
- f"Merging. {current['text']} <{current['type']}> with {previous['text']} <{previous['type']}>")
 
840
  # previous starts with a `-`, sherlock this is another truncated value
841
- current['text'] = previous['text'] + current['text']
842
- current['raw_value'] = current['text']
843
- current['offset_start'] = previous['offset_start']
844
  to_be_removed.append(previous)
845
  else:
846
  print("Other cases to be considered: ", previous, current)
847
  else:
848
- if current['text'].startswith("-"):
849
  print(
850
- f"Merging. {current['text']} <{current['type']}> with {previous['text']} <{previous['type']}>")
 
851
  # previous starts with a `-`, sherlock this is another truncated value
852
- current['text'] = previous['text'] + current['text']
853
- current['raw_value'] = current['text']
854
- current['offset_start'] = previous['offset_start']
855
  to_be_removed.append(previous)
856
  else:
857
  print("Other cases to be considered: ", previous, current)
858
 
859
- elif previous['text'] == "-" and current['text'][0].isdigit():
860
- print(
861
- f"Merging. {current['text']} <{current['type']}> with {previous['text']} <{previous['type']}>")
862
  # previous starts with a `-`, sherlock this is another truncated value
863
- current['text'] = previous['text'] + " " * (current['offset_start'] - previous['offset_end']) + \
864
- current['text']
865
- current['raw_value'] = current['text']
866
- current['offset_start'] = previous['offset_start']
 
867
  to_be_removed.append(previous)
868
  else:
869
  print(
870
- f"Overlapping. {current['text']} <{current['type']}> with {previous['text']} <{previous['type']}>")
 
871
 
872
  # take the largest one
873
- if len(previous['text']) > len(current['text']):
874
  to_be_removed.append(current)
875
- elif len(previous['text']) < len(current['text']):
876
  to_be_removed.append(previous)
877
  else:
878
  to_be_removed.append(previous)
879
- elif current['type'] != previous['type']:
880
- print(
881
- f"Overlapping. {current['text']} <{current['type']}> with {previous['text']} <{previous['type']}>")
882
 
883
- if len(previous['text']) > len(current['text']):
884
  to_be_removed.append(current)
885
- elif len(previous['text']) < len(current['text']):
886
  to_be_removed.append(previous)
887
  else:
888
- if current['type'] == "material":
889
  to_be_removed.append(previous)
890
  else:
891
  to_be_removed.append(current)
892
  previous = current
893
 
894
- elif previous['offset_end'] > current['offset_end']:
895
  to_be_removed.append(current)
896
  # the previous goes after the current, so we keep the previous and we discard the current
897
  else:
898
- if current['type'] == "material":
899
  to_be_removed.append(previous)
900
  else:
901
  to_be_removed.append(current)
@@ -912,11 +943,11 @@ class XmlProcessor(BaseProcessor):
912
 
913
  def process_structure(self, input_file):
914
  text = ""
915
- with open(input_file, encoding='utf-8') as fi:
916
  text = fi.read()
917
 
918
  output_data = self.parse_xml(text)
919
- output_data['filename'] = Path(input_file).stem.replace(".tei", "")
920
 
921
  return output_data
922
 
@@ -931,25 +962,30 @@ class XmlProcessor(BaseProcessor):
931
 
932
  def process(self, text):
933
  output_data = OrderedDict()
934
- soup = BeautifulSoup(text, 'xml')
935
  text_blocks_children = get_children_list_supermat(soup, verbose=False)
936
 
937
  passages = []
938
- output_data['passages'] = passages
939
- passages.extend([
940
- {
941
- "text": self.post_process(''.join(text for text in sentence.find_all(text=True) if
942
- text.parent.name != "ref" or (
943
- text.parent.name == "ref" and text.parent.attrs[
944
- 'type'] != 'bibr'))),
945
- "type": "paragraph",
946
- "section": "<body>",
947
- "subSection": "<paragraph>",
948
- "passage_id": str(paragraph_id) + str(sentence_id)
949
- }
950
- for paragraph_id, paragraph in enumerate(text_blocks_children) for
951
- sentence_id, sentence in enumerate(paragraph)
952
- ])
 
 
 
 
 
953
 
954
  return output_data
955
 
@@ -959,12 +995,12 @@ def get_children_list_supermat(soup, use_paragraphs=False, verbose=False):
959
 
960
  child_name = "p" if use_paragraphs else "s"
961
  for child in soup.tei.children:
962
- if child.name == 'teiHeader':
963
  pass
964
  children.append(child.find_all("title"))
965
  children.extend([subchild.find_all(child_name) for subchild in child.find_all("abstract")])
966
  children.extend([subchild.find_all(child_name) for subchild in child.find_all("ab", {"type": "keywords"})])
967
- elif child.name == 'text':
968
  children.extend([subchild.find_all(child_name) for subchild in child.find_all("body")])
969
 
970
  if verbose:
@@ -978,11 +1014,11 @@ def get_children_list_grobid(soup: object, use_paragraphs: object = True, verbos
978
 
979
  child_name = "p" if use_paragraphs else "s"
980
  for child in soup.TEI.children:
981
- if child.name == 'teiHeader':
982
  pass
983
  # children.extend(child.find_all("title", attrs={"level": "a"}, limit=1))
984
  # children.extend([subchild.find_all(child_name) for subchild in child.find_all("abstract")])
985
- elif child.name == 'text':
986
  children.extend([subchild.find_all(child_name) for subchild in child.find_all("body")])
987
  children.extend([subchild.find_all("figDesc") for subchild in child.find_all("body")])
988
 
@@ -997,9 +1033,12 @@ def get_xml_nodes_header(soup: object, use_paragraphs: bool = True) -> list:
997
 
998
  header_elements = {
999
  "authors": [persNameNode for persNameNode in soup.teiHeader.find_all("persName")],
1000
- "abstract": [p_in_abstract for abstractNodes in soup.teiHeader.find_all("abstract") for p_in_abstract in
1001
- abstractNodes.find_all(sub_tag)],
1002
- "title": [soup.teiHeader.fileDesc.title]
 
 
 
1003
  }
1004
 
1005
  return header_elements
@@ -1009,10 +1048,9 @@ def get_xml_nodes_body(soup: object, use_paragraphs: bool = True, verbose: bool
1009
  nodes = []
1010
  tag_name = "p" if use_paragraphs else "s"
1011
  for child in soup.TEI.children:
1012
- if child.name == 'text':
1013
  # nodes.extend([subchild.find_all(tag_name) for subchild in child.find_all("body")])
1014
- nodes.extend(
1015
- [subsubchild for subchild in child.find_all("body") for subsubchild in subchild.find_all(tag_name)])
1016
 
1017
  if verbose:
1018
  print(str(nodes))
@@ -1024,9 +1062,8 @@ def get_xml_nodes_back(soup: object, use_paragraphs: bool = True, verbose: bool
1024
  nodes = []
1025
  tag_name = "p" if use_paragraphs else "s"
1026
  for child in soup.TEI.children:
1027
- if child.name == 'text':
1028
- nodes.extend(
1029
- [subsubchild for subchild in child.find_all("back") for subsubchild in subchild.find_all(tag_name)])
1030
 
1031
  if verbose:
1032
  print(str(nodes))
@@ -1037,9 +1074,8 @@ def get_xml_nodes_back(soup: object, use_paragraphs: bool = True, verbose: bool
1037
  def get_xml_nodes_figures(soup: object, verbose: bool = False) -> list:
1038
  children = []
1039
  for child in soup.TEI.children:
1040
- if child.name == 'text':
1041
- children.extend(
1042
- [subchild for subchilds in child.find_all("body") for subchild in subchilds.find_all("figDesc")])
1043
 
1044
  if verbose:
1045
  print(str(children))
 
36
  def get_span_start(type, title=None):
37
  """Return an opening ``<span>`` tag for an annotation of the given *type*."""
38
  title_ = ' title="' + title + '"' if title is not None else ""
39
+ return '<span class="label ' + type + '"' + title_ + ">"
40
 
41
 
42
  def get_span_end():
43
+ return "</span>"
44
 
45
 
46
  def get_rs_start(type):
 
48
 
49
 
50
  def get_rs_end():
51
+ return "</rs>"
52
 
53
 
54
  def has_space_between_value_and_unit(quantity):
55
+ return quantity["offsetEnd"] < quantity["rawUnit"]["offsetStart"]
56
 
57
 
58
  def decorate_text_with_annotations(text, spans, tag="span"):
 
70
  Returns:
71
  str: The text with inline annotation markup.
72
  """
73
+ sorted_spans = list(sorted(spans, key=lambda item: item["offset_start"]))
74
  annotated_text = ""
75
  start = 0
76
  for span in sorted_spans:
77
+ type = span["type"].replace("<", "").replace(">", "")
78
+ if "unit_type" in span and span["unit_type"] is not None:
79
+ type = span["unit_type"].replace(" ", "_")
80
+ annotated_text += escape(text[start : span["offset_start"]])
81
+ title = span["quantified"] if "quantified" in span else None
82
  annotated_text += get_span_start(type, title) if tag == "span" else get_rs_start(type)
83
+ annotated_text += escape(text[span["offset_start"] : span["offset_end"]])
84
  annotated_text += get_span_end() if tag == "span" else get_rs_end()
85
 
86
+ start = span["offset_end"]
87
+ annotated_text += escape(text[start : len(text)])
88
  return annotated_text
89
 
90
 
91
  def get_parsed_value_type(quantity):
92
+ if "parsedValue" in quantity and "structure" in quantity["parsedValue"]:
93
+ return quantity["parsedValue"]["structure"]["type"]
94
 
95
 
96
  class BaseProcessor(object):
 
101
  inherit :meth:`post_process` from here.
102
  """
103
 
104
+ patterns = [r"\d+e\d+"]
 
 
105
 
106
  def post_process(self, text):
107
  """Clean encoding artefacts and normalise special characters.
 
112
  Returns:
113
  str: Cleaned text.
114
  """
115
+ output = text.replace("À", "-")
116
+ output = output.replace("¼", "=")
117
+ output = output.replace("þ", "+")
118
+ output = output.replace("Â", "x")
119
+ output = output.replace("$", "~")
120
+ output = output.replace("", "-")
121
+ output = output.replace("", "-")
122
 
123
  for pattern in self.patterns:
124
+ output = re.sub(pattern, lambda match: match.group().replace("e", "-"), output)
125
 
126
  return output
127
 
 
152
  coordinates=["p", "title", "persName"],
153
  sleep_time=5,
154
  timeout=60,
155
+ check_server=ping_server,
156
  )
157
  self.grobid_client = grobid_client
158
 
 
176
  Returns ``None`` if GROBID returns a non-200 status.
177
  """
178
  try:
179
+ pdf_file, status, text = self.grobid_client.process_pdf(
180
+ "processFulltextDocument",
181
+ input_path,
182
+ consolidate_header=True,
183
+ consolidate_citations=False,
184
+ segment_sentences=False,
185
+ tei_coordinates=coordinates,
186
+ include_raw_citations=False,
187
+ include_raw_affiliations=False,
188
+ generateIDs=True,
189
+ )
190
  except requests.exceptions.RequestException as exc:
191
  # Transport-level failure (connection refused, timeout, …).
192
  # Local/usage errors (bad path, parsing bugs) are intentionally
 
205
 
206
  # Grobid can answer 200 with an empty body (e.g. it gave up on the PDF).
207
  if not text or not text.strip():
208
+ raise GrobidServiceError("Grobid returned an empty response.", status_code=status)
 
 
 
209
 
210
  # A truncated/corrupted TEI payload makes the XML parser blow up; map
211
  # that to a clear service error instead of an opaque parsing traceback.
 
214
  except GrobidServiceError:
215
  raise
216
  except Exception as exc:
217
+ raise GrobidServiceError("Grobid returned a malformed or truncated response.", status_code=status) from exc
 
 
 
218
 
219
+ document_object["filename"] = Path(pdf_file).stem.replace(".tei", "")
220
 
221
  # Well-formed XML can still carry no usable text (e.g. an image-only or
222
  # truncated PDF). Nothing to embed downstream, so fail loudly here.
223
+ if not any(passage.get("text", "").strip() for passage in document_object.get("passages", [])):
224
+ raise GrobidServiceError("Grobid returned a document with no extractable text.", status_code=status)
 
 
 
225
 
226
  return document_object
227
 
228
  def process_single(self, input_file):
229
  doc = self.process_structure(input_file)
230
 
231
+ for paragraph in doc["passages"]:
232
+ entities = self.process_single_text(paragraph["text"])
233
+ paragraph["spans"] = entities
234
 
235
  return doc
236
 
 
255
  "doi": doc_biblio.header.doi if doc_biblio.header.doi is not None else "",
256
  "authors": ", ".join([author.full_name for author in doc_biblio.header.authors]),
257
  "title": doc_biblio.header.title,
258
+ "hash": doc_biblio.pdf_md5,
259
  }
260
  try:
261
  year = dateparser.parse(doc_biblio.header.date).year
 
263
  except Exception:
264
  pass
265
 
266
+ output_data["biblio"] = biblio
267
  passages = []
268
+ output_data["passages"] = passages
269
  passage_type = "paragraph"
270
 
271
+ soup = BeautifulSoup(text, "xml")
272
  blocks_header = get_xml_nodes_header(soup, use_paragraphs=True)
273
 
274
  # passages.append({
 
281
  # blocks_header['authors']])
282
  # })
283
 
284
+ passages.append(
285
+ {
286
+ "text": self.post_process(" ".join([node.text for node in blocks_header["title"]])),
287
+ "type": passage_type,
288
+ "section": "<header>",
289
+ "subSection": "<title>",
290
+ "passage_id": "htitle",
291
+ "coordinates": ";".join(
292
+ [node["coords"] if coordinates and node.has_attr("coords") else "" for node in blocks_header["title"]]
293
+ ),
294
+ }
295
+ )
296
+
297
+ passages.append(
298
+ {
299
+ "text": self.post_process(
300
+ "".join(
301
+ node.text
302
+ for node in blocks_header["abstract"]
303
+ for text in node.find_all(text=True)
304
+ if text.parent.name != "ref" or (text.parent.name == "ref" and text.parent.attrs["type"] != "bibr")
305
+ )
306
+ ),
307
+ "type": passage_type,
308
+ "section": "<header>",
309
+ "subSection": "<abstract>",
310
+ "passage_id": "habstract",
311
+ "coordinates": ";".join(
312
+ [node["coords"] if coordinates and node.has_attr("coords") else "" for node in blocks_header["abstract"]]
313
+ ),
314
+ }
315
+ )
316
 
317
  text_blocks_body = get_xml_nodes_body(soup, verbose=False, use_paragraphs=True)
318
  text_blocks_body.extend(get_xml_nodes_back(soup, verbose=False, use_paragraphs=True))
319
 
320
  use_paragraphs = True
321
  if not use_paragraphs:
322
+ passages.extend(
323
+ [
324
+ {
325
+ "text": self.post_process(
326
+ "".join(
327
+ text
328
+ for text in sentence.find_all(text=True)
329
+ if text.parent.name != "ref"
330
+ or (text.parent.name == "ref" and text.parent.attrs["type"] != "bibr")
331
+ )
332
+ ),
333
+ "type": passage_type,
334
+ "section": "<body>",
335
+ "subSection": "<paragraph>",
336
+ "passage_id": str(paragraph_id),
337
+ "coordinates": paragraph["coords"] if coordinates and sentence.has_attr("coords") else "",
338
+ }
339
+ for paragraph_id, paragraph in enumerate(text_blocks_body)
340
+ for sentence_id, sentence in enumerate(paragraph)
341
+ ]
342
+ )
343
  else:
344
+ passages.extend(
345
+ [
346
+ {
347
+ "text": self.post_process(
348
+ "".join(
349
+ text
350
+ for text in paragraph.find_all(text=True)
351
+ if text.parent.name != "ref"
352
+ or (text.parent.name == "ref" and text.parent.attrs["type"] != "bibr")
353
+ )
354
+ ),
355
+ "type": passage_type,
356
+ "section": "<body>",
357
+ "subSection": "<paragraph>",
358
+ "passage_id": str(paragraph_id),
359
+ "coordinates": paragraph["coords"] if coordinates and paragraph.has_attr("coords") else "",
360
+ }
361
+ for paragraph_id, paragraph in enumerate(text_blocks_body)
362
+ ]
363
+ )
364
 
365
  text_blocks_figures = get_xml_nodes_figures(soup, verbose=False)
366
 
367
  if not use_paragraphs:
368
+ passages.extend(
369
+ [
370
+ {
371
+ "text": self.post_process(
372
+ "".join(
373
+ text
374
+ for text in sentence.find_all(text=True)
375
+ if text.parent.name != "ref"
376
+ or (text.parent.name == "ref" and text.parent.attrs["type"] != "bibr")
377
+ )
378
+ ),
379
+ "type": passage_type,
380
+ "section": "<body>",
381
+ "subSection": "<figure>",
382
+ "passage_id": str(paragraph_id) + str(sentence_id),
383
+ "coordinates": sentence["coords"] if coordinates and "coords" in sentence else "",
384
+ }
385
+ for paragraph_id, paragraph in enumerate(text_blocks_figures)
386
+ for sentence_id, sentence in enumerate(paragraph)
387
+ ]
388
+ )
389
  else:
390
+ passages.extend(
391
+ [
392
+ {
393
+ "text": self.post_process(
394
+ "".join(
395
+ text
396
+ for text in paragraph.find_all(text=True)
397
+ if text.parent.name != "ref"
398
+ or (text.parent.name == "ref" and text.parent.attrs["type"] != "bibr")
399
+ )
400
+ ),
401
+ "type": passage_type,
402
+ "section": "<body>",
403
+ "subSection": "<figure>",
404
+ "passage_id": str(paragraph_id),
405
+ "coordinates": paragraph["coords"] if coordinates and paragraph.has_attr("coords") else "",
406
+ }
407
+ for paragraph_id, paragraph in enumerate(text_blocks_figures)
408
+ ]
409
+ )
410
 
411
  return output_data
412
 
 
442
 
443
  spans = []
444
 
445
+ if "measurements" in result:
446
  found_measurements = self.parse_measurements_output(result)
447
 
448
  for m in found_measurements:
449
  item = {
450
+ "text": text[m["offset_start"] : m["offset_end"]],
451
+ "offset_start": m["offset_start"],
452
+ "offset_end": m["offset_end"],
453
  }
454
 
455
+ if "raw" in m and m["raw"] != item["text"]:
456
+ item["text"] = m["raw"]
457
 
458
+ if "quantified_substance" in m:
459
+ item["quantified"] = m["quantified_substance"]
460
 
461
+ if "type" in m:
462
+ item["unit_type"] = m["type"]
463
 
464
+ item["type"] = "property"
465
  # if 'raw_value' in m:
466
  # item['raw_value'] = m['raw_value']
467
 
 
473
  def parse_measurements_output(result):
474
  measurements_output = []
475
 
476
+ for measurement in result["measurements"]:
477
+ type = measurement["type"]
478
  measurement_output_object = {}
479
  quantity_type = None
480
  has_unit = False
481
  parsed_value_type = None
482
 
483
+ if "quantified" in measurement:
484
+ if "normalizedName" in measurement["quantified"]:
485
+ quantified_substance = measurement["quantified"]["normalizedName"]
486
  measurement_output_object["quantified_substance"] = quantified_substance
487
 
488
+ if "measurementOffsets" in measurement:
489
+ measurement_output_object["offset_start"] = measurement["measurementOffsets"]["start"]
490
+ measurement_output_object["offset_end"] = measurement["measurementOffsets"]["end"]
491
  else:
492
  # If there are no offsets we skip the measurement
493
  continue
 
495
  # if 'measurementRaw' in measurement:
496
  # measurement_output_object['raw_value'] = measurement['measurementRaw']
497
 
498
+ if type == "value":
499
+ quantity = measurement["quantity"]
500
 
501
  parsed_value = GrobidQuantitiesProcessor.get_parsed(quantity)
502
  if parsed_value:
503
+ measurement_output_object["parsed"] = parsed_value
504
 
505
  normalized_value = GrobidQuantitiesProcessor.get_normalized(quantity)
506
  if normalized_value:
507
+ measurement_output_object["normalized"] = normalized_value
508
 
509
  raw_value = GrobidQuantitiesProcessor.get_raw(quantity)
510
  if raw_value:
511
+ measurement_output_object["raw"] = raw_value
512
 
513
+ if "type" in quantity:
514
+ quantity_type = quantity["type"]
515
 
516
+ if "rawUnit" in quantity:
517
  has_unit = True
518
 
519
  parsed_value_type = get_parsed_value_type(quantity)
520
 
521
+ elif type == "interval":
522
+ if "quantityMost" in measurement:
523
+ quantityMost = measurement["quantityMost"]
524
+ if "type" in quantityMost:
525
+ quantity_type = quantityMost["type"]
526
 
527
+ if "rawUnit" in quantityMost:
528
  has_unit = True
529
 
530
  parsed_value_type = get_parsed_value_type(quantityMost)
531
 
532
+ if "quantityLeast" in measurement:
533
+ quantityLeast = measurement["quantityLeast"]
534
 
535
+ if "type" in quantityLeast:
536
+ quantity_type = quantityLeast["type"]
537
 
538
+ if "rawUnit" in quantityLeast:
539
  has_unit = True
540
 
541
  parsed_value_type = get_parsed_value_type(quantityLeast)
542
 
543
+ elif type == "listc":
544
+ quantities = measurement["quantities"]
545
 
546
+ if "type" in quantities[0]:
547
+ quantity_type = quantities[0]["type"]
548
 
549
+ if "rawUnit" in quantities[0]:
550
  has_unit = True
551
 
552
  parsed_value_type = get_parsed_value_type(quantities[0])
553
 
554
  if quantity_type is not None or has_unit:
555
+ measurement_output_object["type"] = quantity_type
556
 
557
+ if parsed_value_type is None or parsed_value_type not in ["ALPHABETIC", "TIME"]:
558
  measurements_output.append(measurement_output_object)
559
 
560
  return measurements_output
 
562
  @staticmethod
563
  def get_parsed(quantity):
564
  parsed_value = parsed_unit = None
565
+ if "parsedValue" in quantity and "parsed" in quantity["parsedValue"]:
566
+ parsed_value = quantity["parsedValue"]["parsed"]
567
+ if "parsedUnit" in quantity and "name" in quantity["parsedUnit"]:
568
+ parsed_unit = quantity["parsedUnit"]["name"]
569
 
570
  if parsed_value and parsed_unit:
571
  if has_space_between_value_and_unit(quantity):
 
576
  @staticmethod
577
  def get_normalized(quantity):
578
  normalized_value = normalized_unit = None
579
+ if "normalizedQuantity" in quantity:
580
+ normalized_value = quantity["normalizedQuantity"]
581
+ if "normalizedUnit" in quantity and "name" in quantity["normalizedUnit"]:
582
+ normalized_unit = quantity["normalizedUnit"]["name"]
583
 
584
  if normalized_value and normalized_unit:
585
  if has_space_between_value_and_unit(quantity):
 
590
  @staticmethod
591
  def get_raw(quantity):
592
  raw_value = raw_unit = None
593
+ if "rawValue" in quantity:
594
+ raw_value = quantity["rawValue"]
595
+ if "rawUnit" in quantity and "name" in quantity["rawUnit"]:
596
+ raw_unit = quantity["rawUnit"]["name"]
597
 
598
  if raw_value and raw_unit:
599
  if has_space_between_value_and_unit(quantity):
 
627
  ``type`` (``"material"``), and optional ``formula`` keys.
628
  """
629
  preprocessed_text = text.strip()
630
+ status, result = self.grobid_superconductors_client.process_text(preprocessed_text, "processText_disable_linking")
 
631
 
632
  if status != 200:
633
  result = {}
634
 
635
  spans = []
636
 
637
+ if "passages" in result:
638
  materials = self.parse_superconductors_output(result, preprocessed_text)
639
 
640
  for m in materials:
641
+ item = {"text": preprocessed_text[m["offset_start"] : m["offset_end"]]}
642
 
643
+ item["offset_start"] = m["offset_start"]
644
+ item["offset_end"] = m["offset_end"]
645
 
646
+ if "formula" in m:
647
+ item["formula"] = m["formula"]
648
 
649
+ item["type"] = "material"
650
+ item["raw_value"] = m["text"]
651
 
652
  spans.append(item)
653
 
 
663
  for position_material in result:
664
  compositions = []
665
  for material in position_material:
666
+ if "resolvedFormulas" in material:
667
+ for resolved_formula in material["resolvedFormulas"]:
668
+ if "formulaComposition" in resolved_formula:
669
+ compositions.append(resolved_formula["formulaComposition"])
670
+ elif "formula" in material:
671
+ if "formulaComposition" in material["formula"]:
672
+ compositions.append(material["formula"]["formulaComposition"])
673
  results.append(compositions)
674
 
675
  return results
 
687
  def output_info(self, result):
688
  compositions = []
689
  for material in result:
690
+ if "resolvedFormulas" in material:
691
+ for resolved_formula in material["resolvedFormulas"]:
692
+ if "formulaComposition" in resolved_formula:
693
+ compositions.append(resolved_formula["formulaComposition"])
694
+ elif "formula" in material:
695
+ if "formulaComposition" in material["formula"]:
696
+ compositions.append(material["formula"]["formulaComposition"])
697
+ if "name" in material:
698
+ compositions.append(material["name"])
699
  return compositions
700
 
701
  @staticmethod
702
  def parse_superconductors_output(result, original_text):
703
  materials = []
704
 
705
+ for passage in result["passages"]:
706
+ sentence_offset = original_text.index(passage["text"])
707
+ if "spans" in passage:
708
+ spans = passage["spans"]
709
+ for material_span in filter(lambda s: s["type"] == "<material>", spans):
710
+ text_ = material_span["text"]
711
 
712
  base_material_information = {
713
  "text": text_,
714
+ "offset_start": sentence_offset + material_span["offset_start"],
715
+ "offset_end": sentence_offset + material_span["offset_end"],
716
  }
717
 
718
  materials.append(base_material_information)
 
788
 
789
  item = {"page": box[0], "x": box[1], "y": box[2], "width": box[3], "height": box[4]}
790
  if color:
791
+ item["color"] = color
792
 
793
  if type:
794
+ item["type"] = type
795
 
796
  if border:
797
+ item["border"] = border
798
 
799
  return item
800
 
 
813
  list[dict]: Pruned, non-overlapping spans sorted by offset.
814
  """
815
  # Sorting by offsets
816
+ sorted_entities = sorted(entities, key=lambda d: d["offset_start"])
817
 
818
  if len(entities) <= 1:
819
  return sorted_entities
 
829
  previous = current
830
  continue
831
 
832
+ if (
833
+ previous["offset_start"] < current["offset_start"]
834
+ and previous["offset_end"] < current["offset_end"]
835
+ and (
836
+ previous["offset_end"] < current["offset_start"]
837
+ and not (previous["text"] == "-" and current["text"][0].isdigit())
838
+ )
839
+ ):
840
  previous = current
841
  continue
842
 
843
+ if previous["offset_end"] < current["offset_end"]:
844
+ if current["type"] == previous["type"]:
845
  # Type is the same
846
+ if current["offset_start"] == previous["offset_end"]:
847
+ if current["type"] == "property":
848
+ if current["text"].startswith("."):
849
  print(
850
+ f"Merging. {current['text']} <{current['type']}> with {previous['text']} <{previous['type']}>"
851
+ )
852
  # current entity starts with a ".", suspiciously look like a truncated value
853
  to_be_removed.append(previous)
854
+ current["text"] = previous["text"] + current["text"]
855
+ current["raw_value"] = current["text"]
856
+ current["offset_start"] = previous["offset_start"]
857
+ elif previous["text"].endswith(".") and current["text"][0].isdigit():
858
  print(
859
+ f"Merging. {current['text']} <{current['type']}> with {previous['text']} <{previous['type']}>"
860
+ )
861
  # previous entity ends with ".", current entity starts with a number
862
  to_be_removed.append(previous)
863
+ current["text"] = previous["text"] + current["text"]
864
+ current["raw_value"] = current["text"]
865
+ current["offset_start"] = previous["offset_start"]
866
+ elif previous["text"].startswith("-"):
867
  print(
868
+ f"Merging. {current['text']} <{current['type']}> with {previous['text']} <{previous['type']}>"
869
+ )
870
  # previous starts with a `-`, sherlock this is another truncated value
871
+ current["text"] = previous["text"] + current["text"]
872
+ current["raw_value"] = current["text"]
873
+ current["offset_start"] = previous["offset_start"]
874
  to_be_removed.append(previous)
875
  else:
876
  print("Other cases to be considered: ", previous, current)
877
  else:
878
+ if current["text"].startswith("-"):
879
  print(
880
+ f"Merging. {current['text']} <{current['type']}> with {previous['text']} <{previous['type']}>"
881
+ )
882
  # previous starts with a `-`, sherlock this is another truncated value
883
+ current["text"] = previous["text"] + current["text"]
884
+ current["raw_value"] = current["text"]
885
+ current["offset_start"] = previous["offset_start"]
886
  to_be_removed.append(previous)
887
  else:
888
  print("Other cases to be considered: ", previous, current)
889
 
890
+ elif previous["text"] == "-" and current["text"][0].isdigit():
891
+ print(f"Merging. {current['text']} <{current['type']}> with {previous['text']} <{previous['type']}>")
 
892
  # previous starts with a `-`, sherlock this is another truncated value
893
+ current["text"] = (
894
+ previous["text"] + " " * (current["offset_start"] - previous["offset_end"]) + current["text"]
895
+ )
896
+ current["raw_value"] = current["text"]
897
+ current["offset_start"] = previous["offset_start"]
898
  to_be_removed.append(previous)
899
  else:
900
  print(
901
+ f"Overlapping. {current['text']} <{current['type']}> with {previous['text']} <{previous['type']}>"
902
+ )
903
 
904
  # take the largest one
905
+ if len(previous["text"]) > len(current["text"]):
906
  to_be_removed.append(current)
907
+ elif len(previous["text"]) < len(current["text"]):
908
  to_be_removed.append(previous)
909
  else:
910
  to_be_removed.append(previous)
911
+ elif current["type"] != previous["type"]:
912
+ print(f"Overlapping. {current['text']} <{current['type']}> with {previous['text']} <{previous['type']}>")
 
913
 
914
+ if len(previous["text"]) > len(current["text"]):
915
  to_be_removed.append(current)
916
+ elif len(previous["text"]) < len(current["text"]):
917
  to_be_removed.append(previous)
918
  else:
919
+ if current["type"] == "material":
920
  to_be_removed.append(previous)
921
  else:
922
  to_be_removed.append(current)
923
  previous = current
924
 
925
+ elif previous["offset_end"] > current["offset_end"]:
926
  to_be_removed.append(current)
927
  # the previous goes after the current, so we keep the previous and we discard the current
928
  else:
929
+ if current["type"] == "material":
930
  to_be_removed.append(previous)
931
  else:
932
  to_be_removed.append(current)
 
943
 
944
  def process_structure(self, input_file):
945
  text = ""
946
+ with open(input_file, encoding="utf-8") as fi:
947
  text = fi.read()
948
 
949
  output_data = self.parse_xml(text)
950
+ output_data["filename"] = Path(input_file).stem.replace(".tei", "")
951
 
952
  return output_data
953
 
 
962
 
963
  def process(self, text):
964
  output_data = OrderedDict()
965
+ soup = BeautifulSoup(text, "xml")
966
  text_blocks_children = get_children_list_supermat(soup, verbose=False)
967
 
968
  passages = []
969
+ output_data["passages"] = passages
970
+ passages.extend(
971
+ [
972
+ {
973
+ "text": self.post_process(
974
+ "".join(
975
+ text
976
+ for text in sentence.find_all(text=True)
977
+ if text.parent.name != "ref" or (text.parent.name == "ref" and text.parent.attrs["type"] != "bibr")
978
+ )
979
+ ),
980
+ "type": "paragraph",
981
+ "section": "<body>",
982
+ "subSection": "<paragraph>",
983
+ "passage_id": str(paragraph_id) + str(sentence_id),
984
+ }
985
+ for paragraph_id, paragraph in enumerate(text_blocks_children)
986
+ for sentence_id, sentence in enumerate(paragraph)
987
+ ]
988
+ )
989
 
990
  return output_data
991
 
 
995
 
996
  child_name = "p" if use_paragraphs else "s"
997
  for child in soup.tei.children:
998
+ if child.name == "teiHeader":
999
  pass
1000
  children.append(child.find_all("title"))
1001
  children.extend([subchild.find_all(child_name) for subchild in child.find_all("abstract")])
1002
  children.extend([subchild.find_all(child_name) for subchild in child.find_all("ab", {"type": "keywords"})])
1003
+ elif child.name == "text":
1004
  children.extend([subchild.find_all(child_name) for subchild in child.find_all("body")])
1005
 
1006
  if verbose:
 
1014
 
1015
  child_name = "p" if use_paragraphs else "s"
1016
  for child in soup.TEI.children:
1017
+ if child.name == "teiHeader":
1018
  pass
1019
  # children.extend(child.find_all("title", attrs={"level": "a"}, limit=1))
1020
  # children.extend([subchild.find_all(child_name) for subchild in child.find_all("abstract")])
1021
+ elif child.name == "text":
1022
  children.extend([subchild.find_all(child_name) for subchild in child.find_all("body")])
1023
  children.extend([subchild.find_all("figDesc") for subchild in child.find_all("body")])
1024
 
 
1033
 
1034
  header_elements = {
1035
  "authors": [persNameNode for persNameNode in soup.teiHeader.find_all("persName")],
1036
+ "abstract": [
1037
+ p_in_abstract
1038
+ for abstractNodes in soup.teiHeader.find_all("abstract")
1039
+ for p_in_abstract in abstractNodes.find_all(sub_tag)
1040
+ ],
1041
+ "title": [soup.teiHeader.fileDesc.title],
1042
  }
1043
 
1044
  return header_elements
 
1048
  nodes = []
1049
  tag_name = "p" if use_paragraphs else "s"
1050
  for child in soup.TEI.children:
1051
+ if child.name == "text":
1052
  # nodes.extend([subchild.find_all(tag_name) for subchild in child.find_all("body")])
1053
+ nodes.extend([subsubchild for subchild in child.find_all("body") for subsubchild in subchild.find_all(tag_name)])
 
1054
 
1055
  if verbose:
1056
  print(str(nodes))
 
1062
  nodes = []
1063
  tag_name = "p" if use_paragraphs else "s"
1064
  for child in soup.TEI.children:
1065
+ if child.name == "text":
1066
+ nodes.extend([subsubchild for subchild in child.find_all("back") for subsubchild in subchild.find_all(tag_name)])
 
1067
 
1068
  if verbose:
1069
  print(str(nodes))
 
1074
  def get_xml_nodes_figures(soup: object, verbose: bool = False) -> list:
1075
  children = []
1076
  for child in soup.TEI.children:
1077
+ if child.name == "text":
1078
+ children.extend([subchild for subchilds in child.find_all("body") for subchild in subchilds.find_all("figDesc")])
 
1079
 
1080
  if verbose:
1081
  print(str(children))
document_qa/langchain.py CHANGED
@@ -29,12 +29,10 @@ class AdvancedVectorStoreRetriever(VectorStoreRetriever):
29
  "similarity",
30
  "similarity_score_threshold",
31
  "mmr",
32
- "similarity_with_embeddings"
33
  )
34
 
35
- def _get_relevant_documents(
36
- self, query: str, *, run_manager: CallbackManagerForRetrieverRun
37
- ) -> List[Document]:
38
  """Fetch relevant documents for the configured search type.
39
 
40
  Supports all standard search types plus
@@ -51,28 +49,20 @@ class AdvancedVectorStoreRetriever(VectorStoreRetriever):
51
  """
52
 
53
  if self.search_type == "similarity_with_embeddings":
54
- docs_scores_and_embeddings = (
55
- self.vectorstore.advanced_similarity_search(
56
- query, **self.search_kwargs
57
- )
58
- )
59
 
60
  for doc, score, embeddings in docs_scores_and_embeddings:
61
- if '__embeddings' not in doc.metadata.keys():
62
- doc.metadata['__embeddings'] = embeddings
63
- if '__similarity' not in doc.metadata.keys():
64
- doc.metadata['__similarity'] = score
65
 
66
  docs = [doc for doc, _, _ in docs_scores_and_embeddings]
67
  elif self.search_type == "similarity_score_threshold":
68
- docs_and_similarities = (
69
- self.vectorstore.similarity_search_with_relevance_scores(
70
- query, **self.search_kwargs
71
- )
72
- )
73
  for doc, similarity in docs_and_similarities:
74
- if '__similarity' not in doc.metadata.keys():
75
- doc.metadata['__similarity'] = similarity
76
 
77
  docs = [doc for doc, _ in docs_and_similarities]
78
  else:
@@ -110,22 +100,19 @@ class ChromaAdvancedRetrieval(Chroma, AdvancedVectorStore):
110
 
111
  @xor_args(("query_texts", "query_embeddings"))
112
  def __query_collection(
113
- self,
114
- query_texts: Optional[List[str]] = None,
115
- query_embeddings: Optional[List[List[float]]] = None,
116
- n_results: int = 4,
117
- where: Optional[Dict[str, str]] = None,
118
- where_document: Optional[Dict[str, str]] = None,
119
- **kwargs: Any,
120
  ) -> List[Document]:
121
  """Query the chroma collection."""
122
  try:
123
  import chromadb # noqa: F401
124
  except ImportError:
125
- raise ValueError(
126
- "Could not import chromadb python package. "
127
- "Please install it with `pip install chromadb`."
128
- )
129
  return self._collection.query(
130
  query_texts=query_texts,
131
  query_embeddings=query_embeddings,
@@ -136,11 +123,11 @@ class ChromaAdvancedRetrieval(Chroma, AdvancedVectorStore):
136
  )
137
 
138
  def advanced_similarity_search(
139
- self,
140
- query: str,
141
- k: int = DEFAULT_K,
142
- filter: Optional[Dict[str, str]] = None,
143
- **kwargs: Any,
144
  ) -> List[Tuple[Document, float, List[float]]]:
145
  """Return documents, similarity scores, and embeddings for *query*.
146
 
@@ -157,12 +144,12 @@ class ChromaAdvancedRetrieval(Chroma, AdvancedVectorStore):
157
  return docs_scores_and_embeddings
158
 
159
  def similarity_search_with_scores_and_embeddings(
160
- self,
161
- query: str,
162
- k: int = DEFAULT_K,
163
- filter: Optional[Dict[str, str]] = None,
164
- where_document: Optional[Dict[str, str]] = None,
165
- **kwargs: Any,
166
  ) -> List[Tuple[Document, float, List[float]]]:
167
  """Low-level search returning docs with scores and embeddings.
168
 
@@ -186,7 +173,7 @@ class ChromaAdvancedRetrieval(Chroma, AdvancedVectorStore):
186
  n_results=k,
187
  where=filter,
188
  where_document=where_document,
189
- include=['metadatas', 'documents', 'embeddings', 'distances']
190
  )
191
  else:
192
  query_embedding = self._embedding_function.embed_query(query)
@@ -195,7 +182,7 @@ class ChromaAdvancedRetrieval(Chroma, AdvancedVectorStore):
195
  n_results=k,
196
  where=filter,
197
  where_document=where_document,
198
- include=['metadatas', 'documents', 'embeddings', 'distances']
199
  )
200
 
201
  return _results_to_docs_scores_and_embeddings(results)
 
29
  "similarity",
30
  "similarity_score_threshold",
31
  "mmr",
32
+ "similarity_with_embeddings",
33
  )
34
 
35
+ def _get_relevant_documents(self, query: str, *, run_manager: CallbackManagerForRetrieverRun) -> List[Document]:
 
 
36
  """Fetch relevant documents for the configured search type.
37
 
38
  Supports all standard search types plus
 
49
  """
50
 
51
  if self.search_type == "similarity_with_embeddings":
52
+ docs_scores_and_embeddings = self.vectorstore.advanced_similarity_search(query, **self.search_kwargs)
 
 
 
 
53
 
54
  for doc, score, embeddings in docs_scores_and_embeddings:
55
+ if "__embeddings" not in doc.metadata.keys():
56
+ doc.metadata["__embeddings"] = embeddings
57
+ if "__similarity" not in doc.metadata.keys():
58
+ doc.metadata["__similarity"] = score
59
 
60
  docs = [doc for doc, _, _ in docs_scores_and_embeddings]
61
  elif self.search_type == "similarity_score_threshold":
62
+ docs_and_similarities = self.vectorstore.similarity_search_with_relevance_scores(query, **self.search_kwargs)
 
 
 
 
63
  for doc, similarity in docs_and_similarities:
64
+ if "__similarity" not in doc.metadata.keys():
65
+ doc.metadata["__similarity"] = similarity
66
 
67
  docs = [doc for doc, _ in docs_and_similarities]
68
  else:
 
100
 
101
  @xor_args(("query_texts", "query_embeddings"))
102
  def __query_collection(
103
+ self,
104
+ query_texts: Optional[List[str]] = None,
105
+ query_embeddings: Optional[List[List[float]]] = None,
106
+ n_results: int = 4,
107
+ where: Optional[Dict[str, str]] = None,
108
+ where_document: Optional[Dict[str, str]] = None,
109
+ **kwargs: Any,
110
  ) -> List[Document]:
111
  """Query the chroma collection."""
112
  try:
113
  import chromadb # noqa: F401
114
  except ImportError:
115
+ raise ValueError("Could not import chromadb python package. Please install it with `pip install chromadb`.")
 
 
 
116
  return self._collection.query(
117
  query_texts=query_texts,
118
  query_embeddings=query_embeddings,
 
123
  )
124
 
125
  def advanced_similarity_search(
126
+ self,
127
+ query: str,
128
+ k: int = DEFAULT_K,
129
+ filter: Optional[Dict[str, str]] = None,
130
+ **kwargs: Any,
131
  ) -> List[Tuple[Document, float, List[float]]]:
132
  """Return documents, similarity scores, and embeddings for *query*.
133
 
 
144
  return docs_scores_and_embeddings
145
 
146
  def similarity_search_with_scores_and_embeddings(
147
+ self,
148
+ query: str,
149
+ k: int = DEFAULT_K,
150
+ filter: Optional[Dict[str, str]] = None,
151
+ where_document: Optional[Dict[str, str]] = None,
152
+ **kwargs: Any,
153
  ) -> List[Tuple[Document, float, List[float]]]:
154
  """Low-level search returning docs with scores and embeddings.
155
 
 
173
  n_results=k,
174
  where=filter,
175
  where_document=where_document,
176
+ include=["metadatas", "documents", "embeddings", "distances"],
177
  )
178
  else:
179
  query_embedding = self._embedding_function.embed_query(query)
 
182
  n_results=k,
183
  where=filter,
184
  where_document=where_document,
185
+ include=["metadatas", "documents", "embeddings", "distances"],
186
  )
187
 
188
  return _results_to_docs_scores_and_embeddings(results)
document_qa/ner_client_generic.py CHANGED
@@ -3,12 +3,12 @@ import time
3
 
4
  import yaml
5
 
6
- '''
7
  This client is a generic client for any Grobid application and sub-modules.
8
  At the moment, it supports only single document processing.
9
 
10
  Source: https://github.com/kermitt2/grobid-client-python
11
- '''
12
 
13
  """ Generic API Client """
14
  from copy import deepcopy
@@ -22,24 +22,17 @@ except ImportError:
22
 
23
 
24
  class ApiClient(object):
25
- """ Client to interact with a generic Rest API.
26
 
27
  Subclasses should implement functionality accordingly with the provided
28
  service methods, i.e. ``get``, ``post``, ``put`` and ``delete``.
29
  """
30
 
31
- accept_type = 'application/xml'
32
  api_base = None
33
 
34
- def __init__(
35
- self,
36
- base_url,
37
- username=None,
38
- api_key=None,
39
- status_endpoint=None,
40
- timeout=60
41
- ):
42
- """ Initialise client.
43
 
44
  Args:
45
  base_url (str): The base URL to the service being used.
@@ -55,7 +48,7 @@ class ApiClient(object):
55
 
56
  @staticmethod
57
  def encode(request, data):
58
- """ Add request content data to request body, set Content-type header.
59
 
60
  Should be overridden by subclasses if not using JSON encoding.
61
 
@@ -69,14 +62,14 @@ class ApiClient(object):
69
  if data is None:
70
  return request
71
 
72
- request.add_header('Content-Type', 'application/json')
73
  request.extracted_data = json.dumps(data)
74
 
75
  return request
76
 
77
  @staticmethod
78
  def decode(response):
79
- """ Decode the returned data in the response.
80
 
81
  Should be overridden by subclasses if something else than JSON is
82
  expected.
@@ -93,7 +86,7 @@ class ApiClient(object):
93
  return e.message
94
 
95
  def get_credentials(self):
96
- """ Returns parameters to be added to authenticate the request.
97
 
98
  This lives on its own to make it easier to re-implement it if needed.
99
 
@@ -103,16 +96,16 @@ class ApiClient(object):
103
  return {"username": self.username, "api_key": self.api_key}
104
 
105
  def call_api(
106
- self,
107
- method,
108
- url,
109
- headers=None,
110
- params=None,
111
- data=None,
112
- files=None,
113
- timeout=None,
114
  ):
115
- """ Call API.
116
 
117
  This returns object containing data, with error details if applicable.
118
 
@@ -129,7 +122,7 @@ class ApiClient(object):
129
  ResultParser or ErrorParser.
130
  """
131
  headers = deepcopy(headers) or {}
132
- headers['Accept'] = self.accept_type if 'Accept' not in headers else headers['Accept']
133
  params = deepcopy(params) or {}
134
  data = data or {}
135
  files = files or {}
@@ -148,7 +141,7 @@ class ApiClient(object):
148
  return r, r.status_code
149
 
150
  def get(self, url, params=None, **kwargs):
151
- """ Call the API with a GET request.
152
 
153
  Args:
154
  url (str): Resource location relative to the base URL.
@@ -157,15 +150,10 @@ class ApiClient(object):
157
  Returns:
158
  ResultParser or ErrorParser.
159
  """
160
- return self.call_api(
161
- "GET",
162
- url,
163
- params=params,
164
- **kwargs
165
- )
166
 
167
  def delete(self, url, params=None, **kwargs):
168
- """ Call the API with a DELETE request.
169
 
170
  Args:
171
  url (str): Resource location relative to the base URL.
@@ -174,15 +162,10 @@ class ApiClient(object):
174
  Returns:
175
  ResultParser or ErrorParser.
176
  """
177
- return self.call_api(
178
- "DELETE",
179
- url,
180
- params=params,
181
- **kwargs
182
- )
183
 
184
  def put(self, url, params=None, data=None, files=None, **kwargs):
185
- """ Call the API with a PUT request.
186
 
187
  Args:
188
  url (str): Resource location relative to the base URL.
@@ -193,17 +176,10 @@ class ApiClient(object):
193
  Returns:
194
  An instance of ResultParser or ErrorParser.
195
  """
196
- return self.call_api(
197
- "PUT",
198
- url,
199
- params=params,
200
- data=data,
201
- files=files,
202
- **kwargs
203
- )
204
 
205
  def post(self, url, params=None, data=None, files=None, **kwargs):
206
- """ Call the API with a POST request.
207
 
208
  Args:
209
  url (str): Resource location relative to the base URL.
@@ -214,63 +190,50 @@ class ApiClient(object):
214
  Returns:
215
  An instance of ResultParser or ErrorParser.
216
  """
217
- return self.call_api(
218
- method="POST",
219
- url=url,
220
- params=params,
221
- data=data,
222
- files=files,
223
- **kwargs
224
- )
225
 
226
  def service_status(self, **kwargs):
227
- """ Call the API to get the status of the service.
228
 
229
  Returns:
230
  An instance of ResultParser or ErrorParser.
231
  """
232
- return self.call_api(
233
- 'GET',
234
- self.status_endpoint,
235
- params={'format': 'json'},
236
- **kwargs
237
- )
238
 
239
 
240
  class NERClientGeneric(ApiClient):
241
-
242
  def __init__(self, config_path=None, ping=False):
243
  self.config = None
244
  if config_path is not None:
245
  self.config = self._load_yaml_config_from_file(path=config_path)
246
- super().__init__(self.config['grobid']['server'])
247
 
248
  if ping:
249
  result = self.ping_service()
250
  if not result:
251
  raise Exception("Grobid is down.")
252
 
253
- os.environ['NO_PROXY'] = "nims.go.jp"
254
 
255
  @staticmethod
256
- def _load_json_config_from_file(path='./config.json'):
257
  """
258
  Load the json configuration
259
  """
260
  config = {}
261
- with open(path, 'r') as fp:
262
  config = json.load(fp)
263
 
264
  return config
265
 
266
  @staticmethod
267
- def _load_yaml_config_from_file(path='./config.yaml'):
268
  """
269
  Load the YAML configuration
270
  """
271
  config = {}
272
  try:
273
- with open(path, 'r') as the_file:
274
  raw_configuration = the_file.read()
275
 
276
  config = yaml.safe_load(raw_configuration)
@@ -298,130 +261,86 @@ class NERClientGeneric(ApiClient):
298
  status = r.status_code
299
 
300
  if status != 200:
301
- print('GROBID server does not appear up and running ' + str(status))
302
  return False
303
  else:
304
  print("GROBID server is up and running")
305
  return True
306
 
307
  def get_url(self, action):
308
- grobid_config = self.config['grobid']
309
- base_url = grobid_config['server']
310
- action_url = base_url + grobid_config['url_mapping'][action]
311
 
312
  return action_url
313
 
314
- def process_texts(self, input, method_name='superconductors', params={}, headers={"Accept": "application/json"}):
315
 
316
- files = {
317
- 'texts': input
318
- }
319
 
320
  the_url = self.get_url(method_name)
321
  params, the_url = self.get_params_from_url(the_url)
322
 
323
- res, status = self.post(
324
- url=the_url,
325
- files=files,
326
- data=params,
327
- headers=headers
328
- )
329
 
330
  if status == 503:
331
- time.sleep(self.config['sleep_time'])
332
  return self.process_texts(input, method_name, params, headers)
333
  elif status != 200:
334
- print('Processing failed with error ' + str(status))
335
  return status, None
336
  else:
337
  return status, json.loads(res.text)
338
 
339
- def process_text(self, input, method_name='superconductors', params={}, headers={"Accept": "application/json"}):
340
 
341
- files = {
342
- 'text': input
343
- }
344
 
345
  the_url = self.get_url(method_name)
346
  params, the_url = self.get_params_from_url(the_url)
347
 
348
- res, status = self.post(
349
- url=the_url,
350
- files=files,
351
- data=params,
352
- headers=headers
353
- )
354
 
355
  if status == 503:
356
- time.sleep(self.config['sleep_time'])
357
  return self.process_text(input, method_name, params, headers)
358
  elif status != 200:
359
- print('Processing failed with error ' + str(status))
360
  return status, None
361
  else:
362
  return status, json.loads(res.text)
363
 
364
- def process_pdf(self,
365
- form_data: dict,
366
- method_name='superconductors',
367
- params={},
368
- headers={"Accept": "application/json"}
369
- ):
370
 
371
  the_url = self.get_url(method_name)
372
  params, the_url = self.get_params_from_url(the_url)
373
 
374
- res, status = self.post(
375
- url=the_url,
376
- files=form_data,
377
- data=params,
378
- headers=headers
379
- )
380
 
381
  if status == 503:
382
- time.sleep(self.config['sleep_time'])
383
  return self.process_text(input, method_name, params, headers)
384
  elif status != 200:
385
- print('Processing failed with error ' + str(status))
386
  else:
387
  return res.text
388
 
389
  def process_pdfs(self, pdf_files, params={}):
390
  pass
391
 
392
- def process_pdf(
393
- self,
394
- pdf_file,
395
- method_name,
396
- params={},
397
- headers={"Accept": "application/json"},
398
- verbose=False,
399
- retry=None
400
- ):
401
 
402
- files = {
403
- 'input': (
404
- pdf_file,
405
- open(pdf_file, 'rb'),
406
- 'application/pdf',
407
- {'Expires': '0'}
408
- )
409
- }
410
 
411
  the_url = self.get_url(method_name)
412
 
413
  params, the_url = self.get_params_from_url(the_url)
414
 
415
- res, status = self.post(
416
- url=the_url,
417
- files=files,
418
- data=params,
419
- headers=headers
420
- )
421
 
422
  if status == 503 or status == 429:
423
  if retry is None:
424
- retry = self.config['max_retry'] - 1
425
  else:
426
  if retry - 1 == 0:
427
  if verbose:
@@ -430,7 +349,7 @@ class NERClientGeneric(ApiClient):
430
  else:
431
  retry -= 1
432
 
433
- sleep_time = self.config['sleep_time']
434
  if verbose:
435
  print("Server is saturated, waiting", sleep_time, "seconds and trying again. ")
436
  time.sleep(sleep_time)
@@ -439,7 +358,7 @@ class NERClientGeneric(ApiClient):
439
  desc = None
440
  if res.content:
441
  c = json.loads(res.text)
442
- desc = c['description'] if 'description' in c else None
443
  return desc, status
444
  elif status == 204:
445
  # print('No content returned. Moving on. ')
 
3
 
4
  import yaml
5
 
6
+ """
7
  This client is a generic client for any Grobid application and sub-modules.
8
  At the moment, it supports only single document processing.
9
 
10
  Source: https://github.com/kermitt2/grobid-client-python
11
+ """
12
 
13
  """ Generic API Client """
14
  from copy import deepcopy
 
22
 
23
 
24
  class ApiClient(object):
25
+ """Client to interact with a generic Rest API.
26
 
27
  Subclasses should implement functionality accordingly with the provided
28
  service methods, i.e. ``get``, ``post``, ``put`` and ``delete``.
29
  """
30
 
31
+ accept_type = "application/xml"
32
  api_base = None
33
 
34
+ def __init__(self, base_url, username=None, api_key=None, status_endpoint=None, timeout=60):
35
+ """Initialise client.
 
 
 
 
 
 
 
36
 
37
  Args:
38
  base_url (str): The base URL to the service being used.
 
48
 
49
  @staticmethod
50
  def encode(request, data):
51
+ """Add request content data to request body, set Content-type header.
52
 
53
  Should be overridden by subclasses if not using JSON encoding.
54
 
 
62
  if data is None:
63
  return request
64
 
65
+ request.add_header("Content-Type", "application/json")
66
  request.extracted_data = json.dumps(data)
67
 
68
  return request
69
 
70
  @staticmethod
71
  def decode(response):
72
+ """Decode the returned data in the response.
73
 
74
  Should be overridden by subclasses if something else than JSON is
75
  expected.
 
86
  return e.message
87
 
88
  def get_credentials(self):
89
+ """Returns parameters to be added to authenticate the request.
90
 
91
  This lives on its own to make it easier to re-implement it if needed.
92
 
 
96
  return {"username": self.username, "api_key": self.api_key}
97
 
98
  def call_api(
99
+ self,
100
+ method,
101
+ url,
102
+ headers=None,
103
+ params=None,
104
+ data=None,
105
+ files=None,
106
+ timeout=None,
107
  ):
108
+ """Call API.
109
 
110
  This returns object containing data, with error details if applicable.
111
 
 
122
  ResultParser or ErrorParser.
123
  """
124
  headers = deepcopy(headers) or {}
125
+ headers["Accept"] = self.accept_type if "Accept" not in headers else headers["Accept"]
126
  params = deepcopy(params) or {}
127
  data = data or {}
128
  files = files or {}
 
141
  return r, r.status_code
142
 
143
  def get(self, url, params=None, **kwargs):
144
+ """Call the API with a GET request.
145
 
146
  Args:
147
  url (str): Resource location relative to the base URL.
 
150
  Returns:
151
  ResultParser or ErrorParser.
152
  """
153
+ return self.call_api("GET", url, params=params, **kwargs)
 
 
 
 
 
154
 
155
  def delete(self, url, params=None, **kwargs):
156
+ """Call the API with a DELETE request.
157
 
158
  Args:
159
  url (str): Resource location relative to the base URL.
 
162
  Returns:
163
  ResultParser or ErrorParser.
164
  """
165
+ return self.call_api("DELETE", url, params=params, **kwargs)
 
 
 
 
 
166
 
167
  def put(self, url, params=None, data=None, files=None, **kwargs):
168
+ """Call the API with a PUT request.
169
 
170
  Args:
171
  url (str): Resource location relative to the base URL.
 
176
  Returns:
177
  An instance of ResultParser or ErrorParser.
178
  """
179
+ return self.call_api("PUT", url, params=params, data=data, files=files, **kwargs)
 
 
 
 
 
 
 
180
 
181
  def post(self, url, params=None, data=None, files=None, **kwargs):
182
+ """Call the API with a POST request.
183
 
184
  Args:
185
  url (str): Resource location relative to the base URL.
 
190
  Returns:
191
  An instance of ResultParser or ErrorParser.
192
  """
193
+ return self.call_api(method="POST", url=url, params=params, data=data, files=files, **kwargs)
 
 
 
 
 
 
 
194
 
195
  def service_status(self, **kwargs):
196
+ """Call the API to get the status of the service.
197
 
198
  Returns:
199
  An instance of ResultParser or ErrorParser.
200
  """
201
+ return self.call_api("GET", self.status_endpoint, params={"format": "json"}, **kwargs)
 
 
 
 
 
202
 
203
 
204
  class NERClientGeneric(ApiClient):
 
205
  def __init__(self, config_path=None, ping=False):
206
  self.config = None
207
  if config_path is not None:
208
  self.config = self._load_yaml_config_from_file(path=config_path)
209
+ super().__init__(self.config["grobid"]["server"])
210
 
211
  if ping:
212
  result = self.ping_service()
213
  if not result:
214
  raise Exception("Grobid is down.")
215
 
216
+ os.environ["NO_PROXY"] = "nims.go.jp"
217
 
218
  @staticmethod
219
+ def _load_json_config_from_file(path="./config.json"):
220
  """
221
  Load the json configuration
222
  """
223
  config = {}
224
+ with open(path, "r") as fp:
225
  config = json.load(fp)
226
 
227
  return config
228
 
229
  @staticmethod
230
+ def _load_yaml_config_from_file(path="./config.yaml"):
231
  """
232
  Load the YAML configuration
233
  """
234
  config = {}
235
  try:
236
+ with open(path, "r") as the_file:
237
  raw_configuration = the_file.read()
238
 
239
  config = yaml.safe_load(raw_configuration)
 
261
  status = r.status_code
262
 
263
  if status != 200:
264
+ print("GROBID server does not appear up and running " + str(status))
265
  return False
266
  else:
267
  print("GROBID server is up and running")
268
  return True
269
 
270
  def get_url(self, action):
271
+ grobid_config = self.config["grobid"]
272
+ base_url = grobid_config["server"]
273
+ action_url = base_url + grobid_config["url_mapping"][action]
274
 
275
  return action_url
276
 
277
+ def process_texts(self, input, method_name="superconductors", params={}, headers={"Accept": "application/json"}):
278
 
279
+ files = {"texts": input}
 
 
280
 
281
  the_url = self.get_url(method_name)
282
  params, the_url = self.get_params_from_url(the_url)
283
 
284
+ res, status = self.post(url=the_url, files=files, data=params, headers=headers)
 
 
 
 
 
285
 
286
  if status == 503:
287
+ time.sleep(self.config["sleep_time"])
288
  return self.process_texts(input, method_name, params, headers)
289
  elif status != 200:
290
+ print("Processing failed with error " + str(status))
291
  return status, None
292
  else:
293
  return status, json.loads(res.text)
294
 
295
+ def process_text(self, input, method_name="superconductors", params={}, headers={"Accept": "application/json"}):
296
 
297
+ files = {"text": input}
 
 
298
 
299
  the_url = self.get_url(method_name)
300
  params, the_url = self.get_params_from_url(the_url)
301
 
302
+ res, status = self.post(url=the_url, files=files, data=params, headers=headers)
 
 
 
 
 
303
 
304
  if status == 503:
305
+ time.sleep(self.config["sleep_time"])
306
  return self.process_text(input, method_name, params, headers)
307
  elif status != 200:
308
+ print("Processing failed with error " + str(status))
309
  return status, None
310
  else:
311
  return status, json.loads(res.text)
312
 
313
+ def process_pdf(self, form_data: dict, method_name="superconductors", params={}, headers={"Accept": "application/json"}):
 
 
 
 
 
314
 
315
  the_url = self.get_url(method_name)
316
  params, the_url = self.get_params_from_url(the_url)
317
 
318
+ res, status = self.post(url=the_url, files=form_data, data=params, headers=headers)
 
 
 
 
 
319
 
320
  if status == 503:
321
+ time.sleep(self.config["sleep_time"])
322
  return self.process_text(input, method_name, params, headers)
323
  elif status != 200:
324
+ print("Processing failed with error " + str(status))
325
  else:
326
  return res.text
327
 
328
  def process_pdfs(self, pdf_files, params={}):
329
  pass
330
 
331
+ def process_pdf(self, pdf_file, method_name, params={}, headers={"Accept": "application/json"}, verbose=False, retry=None):
 
 
 
 
 
 
 
 
332
 
333
+ files = {"input": (pdf_file, open(pdf_file, "rb"), "application/pdf", {"Expires": "0"})}
 
 
 
 
 
 
 
334
 
335
  the_url = self.get_url(method_name)
336
 
337
  params, the_url = self.get_params_from_url(the_url)
338
 
339
+ res, status = self.post(url=the_url, files=files, data=params, headers=headers)
 
 
 
 
 
340
 
341
  if status == 503 or status == 429:
342
  if retry is None:
343
+ retry = self.config["max_retry"] - 1
344
  else:
345
  if retry - 1 == 0:
346
  if verbose:
 
349
  else:
350
  retry -= 1
351
 
352
+ sleep_time = self.config["sleep_time"]
353
  if verbose:
354
  print("Server is saturated, waiting", sleep_time, "seconds and trying again. ")
355
  time.sleep(sleep_time)
 
358
  desc = None
359
  if res.content:
360
  c = json.loads(res.text)
361
+ desc = c["description"] if "description" in c else None
362
  return desc, status
363
  elif status == 204:
364
  # print('No content returned. Moving on. ')
pyproject.toml CHANGED
@@ -38,4 +38,12 @@ dependencies = {file = ["requirements.txt"]}
38
  [project.urls]
39
  Homepage = "https://document-insights.streamlit.app"
40
  Repository = "https://github.com/lfoppiano/document-qa"
41
- Changelog = "https://github.com/lfoppiano/document-qa/blob/main/CHANGELOG.md"
 
 
 
 
 
 
 
 
 
38
  [project.urls]
39
  Homepage = "https://document-insights.streamlit.app"
40
  Repository = "https://github.com/lfoppiano/document-qa"
41
+ Changelog = "https://github.com/lfoppiano/document-qa/blob/main/CHANGELOG.md"
42
+
43
+ [tool.ruff]
44
+ # Mirrors the previous flake8 configuration (line length 127, GitHub editor width).
45
+ line-length = 127
46
+
47
+ [tool.ruff.lint]
48
+ # pycodestyle errors (E) + pyflakes (F), matching the old flake8 default rule set.
49
+ select = ["E", "F"]
streamlit_app.py CHANGED
@@ -28,69 +28,64 @@ from document_qa.ner_client_generic import NERClientGeneric
28
 
29
  dotenv.load_dotenv(override=True)
30
 
31
- API_MODELS = {
32
- "microsoft/Phi-4-mini-instruct": os.environ["PHI_URL"],
33
- "Qwen/Qwen3-0.6B": os.environ["QWEN_URL"]
34
- }
35
 
36
- API_EMBEDDINGS = {
37
- 'intfloat/multilingual-e5-large-instruct-modal': os.environ['EMBEDS_URL']
38
- }
39
 
40
- if 'rqa' not in st.session_state:
41
- st.session_state['rqa'] = {}
42
 
43
- if 'model' not in st.session_state:
44
- st.session_state['model'] = None
45
 
46
- if 'api_keys' not in st.session_state:
47
- st.session_state['api_keys'] = {}
48
 
49
- if 'doc_id' not in st.session_state:
50
- st.session_state['doc_id'] = None
51
 
52
- if 'loaded_embeddings' not in st.session_state:
53
- st.session_state['loaded_embeddings'] = None
54
 
55
- if 'hash' not in st.session_state:
56
- st.session_state['hash'] = None
57
 
58
- if 'git_rev' not in st.session_state:
59
- st.session_state['git_rev'] = "unknown"
60
  if os.path.exists("revision.txt"):
61
- with open("revision.txt", 'r') as fr:
62
  from_file = fr.read()
63
- st.session_state['git_rev'] = from_file if len(from_file) > 0 else "unknown"
64
 
65
  if "messages" not in st.session_state:
66
  st.session_state.messages = []
67
 
68
- if 'ner_processing' not in st.session_state:
69
- st.session_state['ner_processing'] = False
70
 
71
- if 'uploaded' not in st.session_state:
72
- st.session_state['uploaded'] = False
73
 
74
- if 'memory' not in st.session_state:
75
- st.session_state['memory'] = None
76
 
77
- if 'binary' not in st.session_state:
78
- st.session_state['binary'] = None
79
 
80
- if 'annotations' not in st.session_state:
81
- st.session_state['annotations'] = None
82
 
83
- if 'should_show_annotations' not in st.session_state:
84
- st.session_state['should_show_annotations'] = True
85
 
86
- if 'pdf' not in st.session_state:
87
- st.session_state['pdf'] = None
88
 
89
- if 'embeddings' not in st.session_state:
90
- st.session_state['embeddings'] = None
91
 
92
- if 'scroll_to_first_annotation' not in st.session_state:
93
- st.session_state['scroll_to_first_annotation'] = False
94
 
95
  st.set_page_config(
96
  page_title="Scientific Document Insights Q/A",
@@ -98,10 +93,10 @@ st.set_page_config(
98
  initial_sidebar_state="expanded",
99
  layout="wide",
100
  menu_items={
101
- 'Get Help': 'https://github.com/lfoppiano/document-qa',
102
- 'Report a bug': "https://github.com/lfoppiano/document-qa/issues",
103
- 'About': "Upload a scientific article in PDF, ask questions, get insights."
104
- }
105
  )
106
 
107
  st.markdown(
@@ -115,7 +110,7 @@ st.markdown(
115
  }
116
  </style>
117
  """,
118
- unsafe_allow_html=True
119
  )
120
 
121
 
@@ -125,17 +120,17 @@ def new_file():
125
  Clears previous embeddings, annotations, and conversation memory
126
  so the pipeline starts fresh for the new document.
127
  """
128
- st.session_state['loaded_embeddings'] = None
129
- st.session_state['doc_id'] = None
130
- st.session_state['uploaded'] = True
131
- st.session_state['annotations'] = []
132
- if st.session_state['memory']:
133
- st.session_state['memory'].clear()
134
 
135
 
136
  def clear_memory():
137
  """Clear the conversation buffer memory (chat history)."""
138
- st.session_state['memory'].clear()
139
 
140
 
141
  # @st.cache_resource
@@ -150,30 +145,16 @@ def init_qa(model_name, embeddings_name):
150
  Returns:
151
  DocumentQAEngine: Ready-to-use engine instance.
152
  """
153
- st.session_state['memory'] = ConversationBufferMemory(
154
- memory_key="chat_history",
155
- return_messages=True
156
- )
157
- chat = ChatOpenAI(
158
- model=model_name,
159
- temperature=0.0,
160
- base_url=API_MODELS[model_name],
161
- api_key=os.environ.get('API_KEY')
162
- )
163
 
164
  embeddings = ModalEmbeddings(
165
- url=API_EMBEDDINGS[embeddings_name],
166
- model_name=embeddings_name,
167
- api_key=os.environ.get('EMBEDS_API_KEY')
168
  )
169
 
170
  storage = DataStorage(embeddings)
171
  return DocumentQAEngine(
172
- chat,
173
- storage,
174
- grobid_url=os.environ['GROBID_URL'],
175
- memory=st.session_state['memory'],
176
- ping_grobid_server=False
177
  )
178
 
179
 
@@ -187,25 +168,26 @@ def init_ner():
187
  Returns:
188
  GrobidAggregationProcessor: Configured processor instance.
189
  """
190
- quantities_client = QuantitiesAPI(os.environ['GROBID_QUANTITIES_URL'], check_server=True)
191
 
192
  materials_client = NERClientGeneric(ping=True)
193
  config_materials = {
194
- 'grobid': {
195
- "server": os.environ['GROBID_MATERIALS_URL'],
196
- 'sleep_time': 5,
197
- 'timeout': 60,
198
- 'url_mapping': {
199
- 'processText_disable_linking': "/service/process/text?disableLinking=True",
200
  # 'processText_disable_linking': "/service/process/text"
201
- }
202
  }
203
  }
204
 
205
  materials_client.set_config(config_materials)
206
 
207
- gqa = GrobidAggregationProcessor(grobid_quantities_client=quantities_client,
208
- grobid_superconductors_client=materials_client)
 
209
  return gqa
210
 
211
 
@@ -230,15 +212,15 @@ def play_old_messages(container):
230
  Called on Streamlit reruns to restore the visible conversation
231
  history from ``st.session_state['messages']``.
232
  """
233
- if st.session_state['messages']:
234
- for message in st.session_state['messages']:
235
- if message['role'] == 'user':
236
- container.chat_message("user").markdown(message['content'])
237
- elif message['role'] == 'assistant':
238
  if mode == "LLM":
239
- container.chat_message("assistant").markdown(message['content'], unsafe_allow_html=True)
240
  else:
241
- container.chat_message("assistant").write(message['content'])
242
 
243
 
244
  # is_api_key_provided = st.session_state['api_key']
@@ -247,37 +229,39 @@ with st.sidebar:
247
  st.title("📝 Document Q/A")
248
  st.markdown("Upload a scientific article in PDF, ask questions, get insights.")
249
  st.markdown(
250
- ":warning: [Usage disclaimer](https://github.com/lfoppiano/document-qa?tab=readme-ov-file#disclaimer-on-data-security-and-privacy-%EF%B8%8F) :warning: ")
 
251
  st.markdown("LM and Embeddings are powered by [Modal.com](https://modal.com/)")
252
 
253
  st.divider()
254
- st.session_state['model'] = model = st.selectbox(
255
  "Model:",
256
  options=API_MODELS.keys(),
257
- index=(list(API_MODELS.keys())).index(
258
- os.environ["DEFAULT_MODEL"]) if "DEFAULT_MODEL" in os.environ and os.environ["DEFAULT_MODEL"] else 0,
 
259
  placeholder="Select model",
260
  help="Select the LLM model:",
261
- disabled=st.session_state['doc_id'] is not None or st.session_state['uploaded']
262
  )
263
 
264
- st.session_state['embeddings'] = embedding_name = st.selectbox(
265
  "Embeddings:",
266
  options=API_EMBEDDINGS.keys(),
267
- index=(list(API_EMBEDDINGS.keys())).index(
268
- os.environ["DEFAULT_EMBEDDING"]) if "DEFAULT_EMBEDDING" in os.environ and os.environ[
269
- "DEFAULT_EMBEDDING"] else 0,
270
  placeholder="Select embedding",
271
  help="Select the Embedding function:",
272
- disabled=st.session_state['doc_id'] is not None or st.session_state['uploaded']
273
  )
274
 
275
- api_key = os.environ['API_KEY']
276
 
277
- if model not in st.session_state['rqa'] or model not in st.session_state['api_keys']:
278
  with st.spinner("Preparing environment"):
279
- st.session_state['rqa'][model] = init_qa(model, st.session_state['embeddings'])
280
- st.session_state['api_keys'][model] = api_key
281
 
282
  left_column, right_column = st.columns([5, 4])
283
  right_column = right_column.container(border=True)
@@ -288,9 +272,8 @@ with right_column:
288
  "Upload a scientific article",
289
  type=("pdf"),
290
  on_change=new_file,
291
- disabled=st.session_state['model'] is not None and st.session_state['model'] not in
292
- st.session_state['api_keys'],
293
- help="The full-text is extracted using Grobid."
294
  )
295
 
296
  placeholder = st.empty()
@@ -299,14 +282,10 @@ with right_column:
299
  question = st.chat_input(
300
  "Ask something about the article",
301
  # placeholder="Can you give me a short summary?",
302
- disabled=not uploaded_file
303
  )
304
 
305
- query_modes = {
306
- "llm": "LLM Q/A",
307
- "embeddings": "Embeddings",
308
- "question_coefficient": "Question coefficient"
309
- }
310
 
311
  with st.sidebar:
312
  st.header("Settings")
@@ -318,53 +297,73 @@ with st.sidebar:
318
  horizontal=True,
319
  format_func=lambda x: query_modes[x],
320
  help="LLM will respond the question, Embedding will show the "
321
- "relevant paragraphs to the question in the paper. "
322
- "Question coefficient attempt to estimate how effective the question will be answered."
323
  )
324
- st.session_state['scroll_to_first_annotation'] = st.checkbox(
325
- "Scroll to context",
326
- help='The PDF viewer will automatically scroll to the first relevant passage in the document.'
327
  )
328
- st.session_state['ner_processing'] = st.checkbox(
329
  "Identify materials and properties.",
330
- help='The LLM responses undergo post-processing to extract physical quantities, measurements, and materials mentions.'
331
  )
332
 
333
  # Add a checkbox for showing annotations
334
  # st.session_state['show_annotations'] = st.checkbox("Show annotations", value=True)
335
  # st.session_state['should_show_annotations'] = st.checkbox("Show annotations", value=True)
336
 
337
- chunk_size = st.slider("Text chunks size", -1, 2000, value=-1,
338
- help="Size of chunks in which split the document. -1: use paragraphs, > 0 paragraphs are aggregated.",
339
- disabled=uploaded_file is not None)
 
 
 
 
 
340
  if chunk_size == -1:
341
- context_size = st.slider("Context size (paragraphs)", 3, 20, value=10,
342
- help="Number of paragraphs to consider when answering a question",
343
- disabled=not uploaded_file)
 
 
 
 
 
344
  else:
345
- context_size = st.slider("Context size (chunks)", 3, 10, value=4,
346
- help="Number of chunks to consider when answering a question",
347
- disabled=not uploaded_file)
 
 
 
 
 
348
 
349
  st.divider()
350
 
351
  st.header("Documentation")
352
  st.markdown("https://github.com/lfoppiano/document-qa")
353
  st.markdown(
354
- """Upload a scientific article as PDF document. Once the spinner stops, you can proceed to ask your questions.""")
 
355
 
356
- if st.session_state['git_rev'] != "unknown":
357
- st.markdown("**Revision number**: [" + st.session_state[
358
- 'git_rev'] + "](https://github.com/lfoppiano/document-qa/commit/" + st.session_state['git_rev'] + ")")
 
 
 
 
 
359
 
360
  if uploaded_file and not st.session_state.loaded_embeddings:
361
- if model not in st.session_state['api_keys']:
362
  st.error("Before uploading a document, you must enter the API key. ")
363
  st.stop()
364
 
365
  with left_column:
366
  try:
367
- with st.spinner('Reading file, calling Grobid, and creating in-memory embeddings...'):
368
  binary = uploaded_file.getvalue()
369
  tmp_path = None
370
  try:
@@ -372,22 +371,20 @@ if uploaded_file and not st.session_state.loaded_embeddings:
372
  tmp_file.write(bytearray(binary))
373
  tmp_file.flush()
374
  tmp_path = tmp_file.name
375
- st.session_state['binary'] = binary
376
 
377
- st.session_state['doc_id'] = st.session_state['rqa'][model].create_memory_embeddings(
378
- tmp_path,
379
- chunk_size=chunk_size,
380
- perc_overlap=0.1
381
  )
382
  finally:
383
  if tmp_path and os.path.exists(tmp_path):
384
  os.unlink(tmp_path)
385
- st.session_state['loaded_embeddings'] = True
386
  st.session_state.messages = []
387
  except GrobidServiceError as exc:
388
- st.session_state['doc_id'] = None
389
- st.session_state['loaded_embeddings'] = False
390
- st.session_state['uploaded'] = False
391
  message = str(exc).strip() or "Grobid is not responding."
392
  if not message.endswith((".", "!", "?")):
393
  message += "."
@@ -418,8 +415,9 @@ def generate_color_gradient(num_elements):
418
 
419
  # Generate a linear gradient of colors
420
  color_gradient = [
421
- rgb_to_hex(tuple(int(warm * (1 - i / num_elements) + cold * (i / num_elements)) for warm, cold in
422
- zip(warm_color, cold_color)))
 
423
  for i in range(num_elements)
424
  ]
425
 
@@ -432,13 +430,13 @@ with right_column:
432
 
433
  for message in st.session_state.messages:
434
  # with messages.chat_message(message["role"]):
435
- if message['mode'] == "llm":
436
  messages.chat_message(message["role"]).markdown(message["content"], unsafe_allow_html=True)
437
- elif message['mode'] == "embeddings":
438
  messages.chat_message(message["role"]).write(message["content"])
439
- elif message['mode'] == "question_coefficient":
440
  messages.chat_message(message["role"]).markdown(message["content"], unsafe_allow_html=True)
441
- if model not in st.session_state['rqa']:
442
  st.error("The API Key for the " + model + " is missing. Please add it before sending any query. `")
443
  st.stop()
444
 
@@ -446,45 +444,40 @@ with right_column:
446
  if mode == "embeddings":
447
  with placeholder:
448
  with st.spinner("Fetching the relevant context..."):
449
- text_response, coordinates = st.session_state['rqa'][model].query_storage(
450
- question,
451
- st.session_state.doc_id,
452
- context_size=context_size
453
  )
454
  elif mode == "llm":
455
  with placeholder:
456
  with st.spinner("Generating LLM response..."):
457
- _, text_response, coordinates = st.session_state['rqa'][model].query_document(
458
- question,
459
- st.session_state.doc_id,
460
- context_size=context_size
461
  )
462
 
463
  elif mode == "question_coefficient":
464
  with st.spinner("Estimate question/context relevancy..."):
465
- text_response, coordinates = st.session_state['rqa'][model].analyse_query(
466
- question,
467
- st.session_state.doc_id,
468
- context_size=context_size
469
  )
470
 
471
- annotations = [[GrobidAggregationProcessor.box_to_dict([cs for cs in c.split(",")]) for c in coord_doc]
472
- for coord_doc in coordinates]
 
 
473
  gradients = generate_color_gradient(len(annotations))
474
  for i, color in enumerate(gradients):
475
  for annotation in annotations[i]:
476
- annotation['color'] = color
477
  if i == 0:
478
- annotation['border'] = "dotted"
479
 
480
- st.session_state['annotations'] = [annotation for annotation_doc in annotations for annotation in
481
- annotation_doc]
482
 
483
  if not text_response:
484
  st.error("Something went wrong. Contact info AT sciencialab.com to report the issue through GitHub.")
485
 
486
  if mode == "llm":
487
- if st.session_state['ner_processing']:
488
  with st.spinner("Processing NER on LLM response..."):
489
  entities = gqa.process_single_text(text_response)
490
  decorated_text = decorate_text_with_annotations(text_response.strip(), entities)
@@ -500,13 +493,14 @@ with right_column:
500
  play_old_messages(messages)
501
 
502
  with left_column:
503
- if st.session_state['binary']:
504
  with st.container(height=600):
505
  pdf_viewer(
506
- input=st.session_state['binary'],
507
  annotation_outline_size=2,
508
- annotations=st.session_state['annotations'] if st.session_state['annotations'] else [],
509
  render_text=True,
510
- scroll_to_annotation=1 if (st.session_state['annotations'] and st.session_state[
511
- 'scroll_to_first_annotation']) else None
 
512
  )
 
28
 
29
  dotenv.load_dotenv(override=True)
30
 
31
+ API_MODELS = {"microsoft/Phi-4-mini-instruct": os.environ["PHI_URL"], "Qwen/Qwen3-0.6B": os.environ["QWEN_URL"]}
 
 
 
32
 
33
+ API_EMBEDDINGS = {"intfloat/multilingual-e5-large-instruct-modal": os.environ["EMBEDS_URL"]}
 
 
34
 
35
+ if "rqa" not in st.session_state:
36
+ st.session_state["rqa"] = {}
37
 
38
+ if "model" not in st.session_state:
39
+ st.session_state["model"] = None
40
 
41
+ if "api_keys" not in st.session_state:
42
+ st.session_state["api_keys"] = {}
43
 
44
+ if "doc_id" not in st.session_state:
45
+ st.session_state["doc_id"] = None
46
 
47
+ if "loaded_embeddings" not in st.session_state:
48
+ st.session_state["loaded_embeddings"] = None
49
 
50
+ if "hash" not in st.session_state:
51
+ st.session_state["hash"] = None
52
 
53
+ if "git_rev" not in st.session_state:
54
+ st.session_state["git_rev"] = "unknown"
55
  if os.path.exists("revision.txt"):
56
+ with open("revision.txt", "r") as fr:
57
  from_file = fr.read()
58
+ st.session_state["git_rev"] = from_file if len(from_file) > 0 else "unknown"
59
 
60
  if "messages" not in st.session_state:
61
  st.session_state.messages = []
62
 
63
+ if "ner_processing" not in st.session_state:
64
+ st.session_state["ner_processing"] = False
65
 
66
+ if "uploaded" not in st.session_state:
67
+ st.session_state["uploaded"] = False
68
 
69
+ if "memory" not in st.session_state:
70
+ st.session_state["memory"] = None
71
 
72
+ if "binary" not in st.session_state:
73
+ st.session_state["binary"] = None
74
 
75
+ if "annotations" not in st.session_state:
76
+ st.session_state["annotations"] = None
77
 
78
+ if "should_show_annotations" not in st.session_state:
79
+ st.session_state["should_show_annotations"] = True
80
 
81
+ if "pdf" not in st.session_state:
82
+ st.session_state["pdf"] = None
83
 
84
+ if "embeddings" not in st.session_state:
85
+ st.session_state["embeddings"] = None
86
 
87
+ if "scroll_to_first_annotation" not in st.session_state:
88
+ st.session_state["scroll_to_first_annotation"] = False
89
 
90
  st.set_page_config(
91
  page_title="Scientific Document Insights Q/A",
 
93
  initial_sidebar_state="expanded",
94
  layout="wide",
95
  menu_items={
96
+ "Get Help": "https://github.com/lfoppiano/document-qa",
97
+ "Report a bug": "https://github.com/lfoppiano/document-qa/issues",
98
+ "About": "Upload a scientific article in PDF, ask questions, get insights.",
99
+ },
100
  )
101
 
102
  st.markdown(
 
110
  }
111
  </style>
112
  """,
113
+ unsafe_allow_html=True,
114
  )
115
 
116
 
 
120
  Clears previous embeddings, annotations, and conversation memory
121
  so the pipeline starts fresh for the new document.
122
  """
123
+ st.session_state["loaded_embeddings"] = None
124
+ st.session_state["doc_id"] = None
125
+ st.session_state["uploaded"] = True
126
+ st.session_state["annotations"] = []
127
+ if st.session_state["memory"]:
128
+ st.session_state["memory"].clear()
129
 
130
 
131
  def clear_memory():
132
  """Clear the conversation buffer memory (chat history)."""
133
+ st.session_state["memory"].clear()
134
 
135
 
136
  # @st.cache_resource
 
145
  Returns:
146
  DocumentQAEngine: Ready-to-use engine instance.
147
  """
148
+ st.session_state["memory"] = ConversationBufferMemory(memory_key="chat_history", return_messages=True)
149
+ chat = ChatOpenAI(model=model_name, temperature=0.0, base_url=API_MODELS[model_name], api_key=os.environ.get("API_KEY"))
 
 
 
 
 
 
 
 
150
 
151
  embeddings = ModalEmbeddings(
152
+ url=API_EMBEDDINGS[embeddings_name], model_name=embeddings_name, api_key=os.environ.get("EMBEDS_API_KEY")
 
 
153
  )
154
 
155
  storage = DataStorage(embeddings)
156
  return DocumentQAEngine(
157
+ chat, storage, grobid_url=os.environ["GROBID_URL"], memory=st.session_state["memory"], ping_grobid_server=False
 
 
 
 
158
  )
159
 
160
 
 
168
  Returns:
169
  GrobidAggregationProcessor: Configured processor instance.
170
  """
171
+ quantities_client = QuantitiesAPI(os.environ["GROBID_QUANTITIES_URL"], check_server=True)
172
 
173
  materials_client = NERClientGeneric(ping=True)
174
  config_materials = {
175
+ "grobid": {
176
+ "server": os.environ["GROBID_MATERIALS_URL"],
177
+ "sleep_time": 5,
178
+ "timeout": 60,
179
+ "url_mapping": {
180
+ "processText_disable_linking": "/service/process/text?disableLinking=True",
181
  # 'processText_disable_linking': "/service/process/text"
182
+ },
183
  }
184
  }
185
 
186
  materials_client.set_config(config_materials)
187
 
188
+ gqa = GrobidAggregationProcessor(
189
+ grobid_quantities_client=quantities_client, grobid_superconductors_client=materials_client
190
+ )
191
  return gqa
192
 
193
 
 
212
  Called on Streamlit reruns to restore the visible conversation
213
  history from ``st.session_state['messages']``.
214
  """
215
+ if st.session_state["messages"]:
216
+ for message in st.session_state["messages"]:
217
+ if message["role"] == "user":
218
+ container.chat_message("user").markdown(message["content"])
219
+ elif message["role"] == "assistant":
220
  if mode == "LLM":
221
+ container.chat_message("assistant").markdown(message["content"], unsafe_allow_html=True)
222
  else:
223
+ container.chat_message("assistant").write(message["content"])
224
 
225
 
226
  # is_api_key_provided = st.session_state['api_key']
 
229
  st.title("📝 Document Q/A")
230
  st.markdown("Upload a scientific article in PDF, ask questions, get insights.")
231
  st.markdown(
232
+ ":warning: [Usage disclaimer](https://github.com/lfoppiano/document-qa?tab=readme-ov-file#disclaimer-on-data-security-and-privacy-%EF%B8%8F) :warning: "
233
+ )
234
  st.markdown("LM and Embeddings are powered by [Modal.com](https://modal.com/)")
235
 
236
  st.divider()
237
+ st.session_state["model"] = model = st.selectbox(
238
  "Model:",
239
  options=API_MODELS.keys(),
240
+ index=(list(API_MODELS.keys())).index(os.environ["DEFAULT_MODEL"])
241
+ if "DEFAULT_MODEL" in os.environ and os.environ["DEFAULT_MODEL"]
242
+ else 0,
243
  placeholder="Select model",
244
  help="Select the LLM model:",
245
+ disabled=st.session_state["doc_id"] is not None or st.session_state["uploaded"],
246
  )
247
 
248
+ st.session_state["embeddings"] = embedding_name = st.selectbox(
249
  "Embeddings:",
250
  options=API_EMBEDDINGS.keys(),
251
+ index=(list(API_EMBEDDINGS.keys())).index(os.environ["DEFAULT_EMBEDDING"])
252
+ if "DEFAULT_EMBEDDING" in os.environ and os.environ["DEFAULT_EMBEDDING"]
253
+ else 0,
254
  placeholder="Select embedding",
255
  help="Select the Embedding function:",
256
+ disabled=st.session_state["doc_id"] is not None or st.session_state["uploaded"],
257
  )
258
 
259
+ api_key = os.environ["API_KEY"]
260
 
261
+ if model not in st.session_state["rqa"] or model not in st.session_state["api_keys"]:
262
  with st.spinner("Preparing environment"):
263
+ st.session_state["rqa"][model] = init_qa(model, st.session_state["embeddings"])
264
+ st.session_state["api_keys"][model] = api_key
265
 
266
  left_column, right_column = st.columns([5, 4])
267
  right_column = right_column.container(border=True)
 
272
  "Upload a scientific article",
273
  type=("pdf"),
274
  on_change=new_file,
275
+ disabled=st.session_state["model"] is not None and st.session_state["model"] not in st.session_state["api_keys"],
276
+ help="The full-text is extracted using Grobid.",
 
277
  )
278
 
279
  placeholder = st.empty()
 
282
  question = st.chat_input(
283
  "Ask something about the article",
284
  # placeholder="Can you give me a short summary?",
285
+ disabled=not uploaded_file,
286
  )
287
 
288
+ query_modes = {"llm": "LLM Q/A", "embeddings": "Embeddings", "question_coefficient": "Question coefficient"}
 
 
 
 
289
 
290
  with st.sidebar:
291
  st.header("Settings")
 
297
  horizontal=True,
298
  format_func=lambda x: query_modes[x],
299
  help="LLM will respond the question, Embedding will show the "
300
+ "relevant paragraphs to the question in the paper. "
301
+ "Question coefficient attempt to estimate how effective the question will be answered.",
302
  )
303
+ st.session_state["scroll_to_first_annotation"] = st.checkbox(
304
+ "Scroll to context", help="The PDF viewer will automatically scroll to the first relevant passage in the document."
 
305
  )
306
+ st.session_state["ner_processing"] = st.checkbox(
307
  "Identify materials and properties.",
308
+ help="The LLM responses undergo post-processing to extract physical quantities, measurements, and materials mentions.",
309
  )
310
 
311
  # Add a checkbox for showing annotations
312
  # st.session_state['show_annotations'] = st.checkbox("Show annotations", value=True)
313
  # st.session_state['should_show_annotations'] = st.checkbox("Show annotations", value=True)
314
 
315
+ chunk_size = st.slider(
316
+ "Text chunks size",
317
+ -1,
318
+ 2000,
319
+ value=-1,
320
+ help="Size of chunks in which split the document. -1: use paragraphs, > 0 paragraphs are aggregated.",
321
+ disabled=uploaded_file is not None,
322
+ )
323
  if chunk_size == -1:
324
+ context_size = st.slider(
325
+ "Context size (paragraphs)",
326
+ 3,
327
+ 20,
328
+ value=10,
329
+ help="Number of paragraphs to consider when answering a question",
330
+ disabled=not uploaded_file,
331
+ )
332
  else:
333
+ context_size = st.slider(
334
+ "Context size (chunks)",
335
+ 3,
336
+ 10,
337
+ value=4,
338
+ help="Number of chunks to consider when answering a question",
339
+ disabled=not uploaded_file,
340
+ )
341
 
342
  st.divider()
343
 
344
  st.header("Documentation")
345
  st.markdown("https://github.com/lfoppiano/document-qa")
346
  st.markdown(
347
+ """Upload a scientific article as PDF document. Once the spinner stops, you can proceed to ask your questions."""
348
+ )
349
 
350
+ if st.session_state["git_rev"] != "unknown":
351
+ st.markdown(
352
+ "**Revision number**: ["
353
+ + st.session_state["git_rev"]
354
+ + "](https://github.com/lfoppiano/document-qa/commit/"
355
+ + st.session_state["git_rev"]
356
+ + ")"
357
+ )
358
 
359
  if uploaded_file and not st.session_state.loaded_embeddings:
360
+ if model not in st.session_state["api_keys"]:
361
  st.error("Before uploading a document, you must enter the API key. ")
362
  st.stop()
363
 
364
  with left_column:
365
  try:
366
+ with st.spinner("Reading file, calling Grobid, and creating in-memory embeddings..."):
367
  binary = uploaded_file.getvalue()
368
  tmp_path = None
369
  try:
 
371
  tmp_file.write(bytearray(binary))
372
  tmp_file.flush()
373
  tmp_path = tmp_file.name
374
+ st.session_state["binary"] = binary
375
 
376
+ st.session_state["doc_id"] = st.session_state["rqa"][model].create_memory_embeddings(
377
+ tmp_path, chunk_size=chunk_size, perc_overlap=0.1
 
 
378
  )
379
  finally:
380
  if tmp_path and os.path.exists(tmp_path):
381
  os.unlink(tmp_path)
382
+ st.session_state["loaded_embeddings"] = True
383
  st.session_state.messages = []
384
  except GrobidServiceError as exc:
385
+ st.session_state["doc_id"] = None
386
+ st.session_state["loaded_embeddings"] = False
387
+ st.session_state["uploaded"] = False
388
  message = str(exc).strip() or "Grobid is not responding."
389
  if not message.endswith((".", "!", "?")):
390
  message += "."
 
415
 
416
  # Generate a linear gradient of colors
417
  color_gradient = [
418
+ rgb_to_hex(
419
+ tuple(int(warm * (1 - i / num_elements) + cold * (i / num_elements)) for warm, cold in zip(warm_color, cold_color))
420
+ )
421
  for i in range(num_elements)
422
  ]
423
 
 
430
 
431
  for message in st.session_state.messages:
432
  # with messages.chat_message(message["role"]):
433
+ if message["mode"] == "llm":
434
  messages.chat_message(message["role"]).markdown(message["content"], unsafe_allow_html=True)
435
+ elif message["mode"] == "embeddings":
436
  messages.chat_message(message["role"]).write(message["content"])
437
+ elif message["mode"] == "question_coefficient":
438
  messages.chat_message(message["role"]).markdown(message["content"], unsafe_allow_html=True)
439
+ if model not in st.session_state["rqa"]:
440
  st.error("The API Key for the " + model + " is missing. Please add it before sending any query. `")
441
  st.stop()
442
 
 
444
  if mode == "embeddings":
445
  with placeholder:
446
  with st.spinner("Fetching the relevant context..."):
447
+ text_response, coordinates = st.session_state["rqa"][model].query_storage(
448
+ question, st.session_state.doc_id, context_size=context_size
 
 
449
  )
450
  elif mode == "llm":
451
  with placeholder:
452
  with st.spinner("Generating LLM response..."):
453
+ _, text_response, coordinates = st.session_state["rqa"][model].query_document(
454
+ question, st.session_state.doc_id, context_size=context_size
 
 
455
  )
456
 
457
  elif mode == "question_coefficient":
458
  with st.spinner("Estimate question/context relevancy..."):
459
+ text_response, coordinates = st.session_state["rqa"][model].analyse_query(
460
+ question, st.session_state.doc_id, context_size=context_size
 
 
461
  )
462
 
463
+ annotations = [
464
+ [GrobidAggregationProcessor.box_to_dict([cs for cs in c.split(",")]) for c in coord_doc]
465
+ for coord_doc in coordinates
466
+ ]
467
  gradients = generate_color_gradient(len(annotations))
468
  for i, color in enumerate(gradients):
469
  for annotation in annotations[i]:
470
+ annotation["color"] = color
471
  if i == 0:
472
+ annotation["border"] = "dotted"
473
 
474
+ st.session_state["annotations"] = [annotation for annotation_doc in annotations for annotation in annotation_doc]
 
475
 
476
  if not text_response:
477
  st.error("Something went wrong. Contact info AT sciencialab.com to report the issue through GitHub.")
478
 
479
  if mode == "llm":
480
+ if st.session_state["ner_processing"]:
481
  with st.spinner("Processing NER on LLM response..."):
482
  entities = gqa.process_single_text(text_response)
483
  decorated_text = decorate_text_with_annotations(text_response.strip(), entities)
 
493
  play_old_messages(messages)
494
 
495
  with left_column:
496
+ if st.session_state["binary"]:
497
  with st.container(height=600):
498
  pdf_viewer(
499
+ input=st.session_state["binary"],
500
  annotation_outline_size=2,
501
+ annotations=st.session_state["annotations"] if st.session_state["annotations"] else [],
502
  render_text=True,
503
+ scroll_to_annotation=1
504
+ if (st.session_state["annotations"] and st.session_state["scroll_to_first_annotation"])
505
+ else None,
506
  )