Spaces:
Build error
Build error
Upload folder using huggingface_hub
Browse files- .env.example +13 -14
- README.md +3 -1
- docs/README.md +7 -0
- document_qa/custom_embeddings.py +13 -15
- document_qa/deployment/README.md +104 -0
- document_qa/deployment/_embeddings_app.py +116 -0
- document_qa/deployment/modal_embeddings_en.py +34 -0
- document_qa/deployment/modal_embeddings_multilang.py +34 -0
- document_qa/deployment/modal_inference_phi.py +4 -6
- document_qa/deployment/modal_inference_qwen.py +6 -7
- document_qa/document_qa_engine.py +69 -113
- document_qa/grobid_processors.py +349 -313
- document_qa/langchain.py +31 -44
- document_qa/ner_client_generic.py +60 -141
- pyproject.toml +9 -1
- streamlit_app.py +166 -172
.env.example
CHANGED
|
@@ -1,18 +1,17 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
DEFAULT_MODEL=microsoft/Phi-4-mini-instruct
|
| 6 |
DEFAULT_EMBEDDING=intfloat/multilingual-e5-large-instruct-modal
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 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 |
-
-
|
| 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 = {
|
| 51 |
|
| 52 |
headers = {}
|
| 53 |
if self.api_key:
|
| 54 |
-
headers = {
|
| 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=
|
| 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.
|
| 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("
|
| 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 |
-
|
|
|
|
| 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[
|
| 93 |
-
coordinates = passage[
|
| 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 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 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(
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 291 |
-
|
| 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[
|
| 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 |
-
|
| 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[
|
| 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 = [
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
all_documents = db.get(include=[
|
| 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[
|
| 457 |
min_similarity = min(similarities)
|
| 458 |
mean_similarity = sum(similarities) / len(similarities)
|
| 459 |
coefficient = min_similarity - mean_similarity
|
| 460 |
|
| 461 |
-
return
|
|
|
|
|
|
|
|
|
|
| 462 |
|
| 463 |
def _parse_json(self, response, output_parser):
|
| 464 |
-
system_message =
|
| 465 |
-
|
|
|
|
|
|
|
| 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[
|
| 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[
|
| 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[
|
| 551 |
-
biblio[
|
| 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[
|
| 562 |
else:
|
| 563 |
-
new_passages = structure[
|
| 564 |
|
| 565 |
for passage in new_passages:
|
| 566 |
biblio_copy = copy.copy(biblio)
|
| 567 |
-
if len(str.strip(passage[
|
| 568 |
-
texts.append(passage[
|
| 569 |
|
| 570 |
-
biblio_copy[
|
| 571 |
-
biblio_copy[
|
| 572 |
-
biblio_copy[
|
| 573 |
-
biblio_copy[
|
| 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][
|
| 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=
|
| 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 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 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"),
|
| 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,
|
| 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
|
| 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
|
| 52 |
|
| 53 |
|
| 54 |
def has_space_between_value_and_unit(quantity):
|
| 55 |
-
return quantity[
|
| 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[
|
| 74 |
annotated_text = ""
|
| 75 |
start = 0
|
| 76 |
for span in sorted_spans:
|
| 77 |
-
type = span[
|
| 78 |
-
if
|
| 79 |
-
type = span[
|
| 80 |
-
annotated_text += escape(text[start: span[
|
| 81 |
-
title = span[
|
| 82 |
annotated_text += get_span_start(type, title) if tag == "span" else get_rs_start(type)
|
| 83 |
-
annotated_text += escape(text[span[
|
| 84 |
annotated_text += get_span_end() if tag == "span" else get_rs_end()
|
| 85 |
|
| 86 |
-
start = span[
|
| 87 |
-
annotated_text += escape(text[start: len(text)])
|
| 88 |
return annotated_text
|
| 89 |
|
| 90 |
|
| 91 |
def get_parsed_value_type(quantity):
|
| 92 |
-
if
|
| 93 |
-
return quantity[
|
| 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(
|
| 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(
|
| 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(
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 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,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[
|
| 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(
|
| 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[
|
| 241 |
-
entities = self.process_single_text(paragraph[
|
| 242 |
-
paragraph[
|
| 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[
|
| 276 |
passages = []
|
| 277 |
-
output_data[
|
| 278 |
passage_type = "paragraph"
|
| 279 |
|
| 280 |
-
soup = BeautifulSoup(text,
|
| 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 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 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 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
else:
|
| 338 |
-
passages.extend(
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
|
| 353 |
text_blocks_figures = get_xml_nodes_figures(soup, verbose=False)
|
| 354 |
|
| 355 |
if not use_paragraphs:
|
| 356 |
-
passages.extend(
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
else:
|
| 372 |
-
passages.extend(
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
|
| 387 |
return output_data
|
| 388 |
|
|
@@ -418,26 +442,26 @@ class GrobidQuantitiesProcessor(BaseProcessor):
|
|
| 418 |
|
| 419 |
spans = []
|
| 420 |
|
| 421 |
-
if
|
| 422 |
found_measurements = self.parse_measurements_output(result)
|
| 423 |
|
| 424 |
for m in found_measurements:
|
| 425 |
item = {
|
| 426 |
-
"text": text[m[
|
| 427 |
-
|
| 428 |
-
|
| 429 |
}
|
| 430 |
|
| 431 |
-
if
|
| 432 |
-
item[
|
| 433 |
|
| 434 |
-
if
|
| 435 |
-
item[
|
| 436 |
|
| 437 |
-
if
|
| 438 |
-
item["unit_type"] = m[
|
| 439 |
|
| 440 |
-
item[
|
| 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[
|
| 453 |
-
type = measurement[
|
| 454 |
measurement_output_object = {}
|
| 455 |
quantity_type = None
|
| 456 |
has_unit = False
|
| 457 |
parsed_value_type = None
|
| 458 |
|
| 459 |
-
if
|
| 460 |
-
if
|
| 461 |
-
quantified_substance = measurement[
|
| 462 |
measurement_output_object["quantified_substance"] = quantified_substance
|
| 463 |
|
| 464 |
-
if
|
| 465 |
-
measurement_output_object["offset_start"] = measurement["measurementOffsets"][
|
| 466 |
-
measurement_output_object["offset_end"] = measurement["measurementOffsets"][
|
| 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 ==
|
| 475 |
-
quantity = measurement[
|
| 476 |
|
| 477 |
parsed_value = GrobidQuantitiesProcessor.get_parsed(quantity)
|
| 478 |
if parsed_value:
|
| 479 |
-
measurement_output_object[
|
| 480 |
|
| 481 |
normalized_value = GrobidQuantitiesProcessor.get_normalized(quantity)
|
| 482 |
if normalized_value:
|
| 483 |
-
measurement_output_object[
|
| 484 |
|
| 485 |
raw_value = GrobidQuantitiesProcessor.get_raw(quantity)
|
| 486 |
if raw_value:
|
| 487 |
-
measurement_output_object[
|
| 488 |
|
| 489 |
-
if
|
| 490 |
-
quantity_type = quantity[
|
| 491 |
|
| 492 |
-
if
|
| 493 |
has_unit = True
|
| 494 |
|
| 495 |
parsed_value_type = get_parsed_value_type(quantity)
|
| 496 |
|
| 497 |
-
elif type ==
|
| 498 |
-
if
|
| 499 |
-
quantityMost = measurement[
|
| 500 |
-
if
|
| 501 |
-
quantity_type = quantityMost[
|
| 502 |
|
| 503 |
-
if
|
| 504 |
has_unit = True
|
| 505 |
|
| 506 |
parsed_value_type = get_parsed_value_type(quantityMost)
|
| 507 |
|
| 508 |
-
if
|
| 509 |
-
quantityLeast = measurement[
|
| 510 |
|
| 511 |
-
if
|
| 512 |
-
quantity_type = quantityLeast[
|
| 513 |
|
| 514 |
-
if
|
| 515 |
has_unit = True
|
| 516 |
|
| 517 |
parsed_value_type = get_parsed_value_type(quantityLeast)
|
| 518 |
|
| 519 |
-
elif type ==
|
| 520 |
-
quantities = measurement[
|
| 521 |
|
| 522 |
-
if
|
| 523 |
-
quantity_type = quantities[0][
|
| 524 |
|
| 525 |
-
if
|
| 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[
|
| 532 |
|
| 533 |
-
if parsed_value_type is None or parsed_value_type not in [
|
| 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
|
| 542 |
-
parsed_value = quantity[
|
| 543 |
-
if
|
| 544 |
-
parsed_unit = quantity[
|
| 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
|
| 556 |
-
normalized_value = quantity[
|
| 557 |
-
if
|
| 558 |
-
normalized_unit = quantity[
|
| 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
|
| 570 |
-
raw_value = quantity[
|
| 571 |
-
if
|
| 572 |
-
raw_unit = quantity[
|
| 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
|
| 615 |
materials = self.parse_superconductors_output(result, preprocessed_text)
|
| 616 |
|
| 617 |
for m in materials:
|
| 618 |
-
item = {"text": preprocessed_text[m[
|
| 619 |
|
| 620 |
-
item[
|
| 621 |
-
item[
|
| 622 |
|
| 623 |
-
if
|
| 624 |
-
item["formula"] = m[
|
| 625 |
|
| 626 |
-
item[
|
| 627 |
-
item[
|
| 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
|
| 644 |
-
for resolved_formula in material[
|
| 645 |
-
if
|
| 646 |
-
compositions.append(resolved_formula[
|
| 647 |
-
elif
|
| 648 |
-
if
|
| 649 |
-
compositions.append(material[
|
| 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
|
| 668 |
-
for resolved_formula in material[
|
| 669 |
-
if
|
| 670 |
-
compositions.append(resolved_formula[
|
| 671 |
-
elif
|
| 672 |
-
if
|
| 673 |
-
compositions.append(material[
|
| 674 |
-
if
|
| 675 |
-
compositions.append(material[
|
| 676 |
return compositions
|
| 677 |
|
| 678 |
@staticmethod
|
| 679 |
def parse_superconductors_output(result, original_text):
|
| 680 |
materials = []
|
| 681 |
|
| 682 |
-
for passage in result[
|
| 683 |
-
sentence_offset = original_text.index(passage[
|
| 684 |
-
if
|
| 685 |
-
spans = passage[
|
| 686 |
-
for material_span in filter(lambda s: s[
|
| 687 |
-
text_ = material_span[
|
| 688 |
|
| 689 |
base_material_information = {
|
| 690 |
"text": text_,
|
| 691 |
-
"offset_start": sentence_offset + material_span[
|
| 692 |
-
|
| 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[
|
| 769 |
|
| 770 |
if type:
|
| 771 |
-
item[
|
| 772 |
|
| 773 |
if border:
|
| 774 |
-
item[
|
| 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[
|
| 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
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 813 |
previous = current
|
| 814 |
continue
|
| 815 |
|
| 816 |
-
if previous[
|
| 817 |
-
if current[
|
| 818 |
# Type is the same
|
| 819 |
-
if current[
|
| 820 |
-
if current[
|
| 821 |
-
if current[
|
| 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[
|
| 827 |
-
current[
|
| 828 |
-
current[
|
| 829 |
-
elif previous[
|
| 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[
|
| 835 |
-
current[
|
| 836 |
-
current[
|
| 837 |
-
elif previous[
|
| 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[
|
| 842 |
-
current[
|
| 843 |
-
current[
|
| 844 |
to_be_removed.append(previous)
|
| 845 |
else:
|
| 846 |
print("Other cases to be considered: ", previous, current)
|
| 847 |
else:
|
| 848 |
-
if current[
|
| 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[
|
| 853 |
-
current[
|
| 854 |
-
current[
|
| 855 |
to_be_removed.append(previous)
|
| 856 |
else:
|
| 857 |
print("Other cases to be considered: ", previous, current)
|
| 858 |
|
| 859 |
-
elif previous[
|
| 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[
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
current[
|
|
|
|
| 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[
|
| 874 |
to_be_removed.append(current)
|
| 875 |
-
elif len(previous[
|
| 876 |
to_be_removed.append(previous)
|
| 877 |
else:
|
| 878 |
to_be_removed.append(previous)
|
| 879 |
-
elif current[
|
| 880 |
-
print(
|
| 881 |
-
f"Overlapping. {current['text']} <{current['type']}> with {previous['text']} <{previous['type']}>")
|
| 882 |
|
| 883 |
-
if len(previous[
|
| 884 |
to_be_removed.append(current)
|
| 885 |
-
elif len(previous[
|
| 886 |
to_be_removed.append(previous)
|
| 887 |
else:
|
| 888 |
-
if current[
|
| 889 |
to_be_removed.append(previous)
|
| 890 |
else:
|
| 891 |
to_be_removed.append(current)
|
| 892 |
previous = current
|
| 893 |
|
| 894 |
-
elif previous[
|
| 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[
|
| 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=
|
| 916 |
text = fi.read()
|
| 917 |
|
| 918 |
output_data = self.parse_xml(text)
|
| 919 |
-
output_data[
|
| 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,
|
| 935 |
text_blocks_children = get_children_list_supermat(soup, verbose=False)
|
| 936 |
|
| 937 |
passages = []
|
| 938 |
-
output_data[
|
| 939 |
-
passages.extend(
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 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 ==
|
| 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 ==
|
| 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 ==
|
| 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 ==
|
| 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": [
|
| 1001 |
-
|
| 1002 |
-
|
|
|
|
|
|
|
|
|
|
| 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 ==
|
| 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 ==
|
| 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 ==
|
| 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
|
| 62 |
-
doc.metadata[
|
| 63 |
-
if
|
| 64 |
-
doc.metadata[
|
| 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
|
| 75 |
-
doc.metadata[
|
| 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 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 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 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 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 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 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=[
|
| 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=[
|
| 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 |
-
"""
|
| 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 =
|
| 32 |
api_base = None
|
| 33 |
|
| 34 |
-
def __init__(
|
| 35 |
-
|
| 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 |
-
"""
|
| 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(
|
| 73 |
request.extracted_data = json.dumps(data)
|
| 74 |
|
| 75 |
return request
|
| 76 |
|
| 77 |
@staticmethod
|
| 78 |
def decode(response):
|
| 79 |
-
"""
|
| 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 |
-
"""
|
| 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 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
):
|
| 115 |
-
"""
|
| 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[
|
| 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 |
-
"""
|
| 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 |
-
"""
|
| 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 |
-
"""
|
| 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 |
-
"""
|
| 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 |
-
"""
|
| 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[
|
| 247 |
|
| 248 |
if ping:
|
| 249 |
result = self.ping_service()
|
| 250 |
if not result:
|
| 251 |
raise Exception("Grobid is down.")
|
| 252 |
|
| 253 |
-
os.environ[
|
| 254 |
|
| 255 |
@staticmethod
|
| 256 |
-
def _load_json_config_from_file(path=
|
| 257 |
"""
|
| 258 |
Load the json configuration
|
| 259 |
"""
|
| 260 |
config = {}
|
| 261 |
-
with open(path,
|
| 262 |
config = json.load(fp)
|
| 263 |
|
| 264 |
return config
|
| 265 |
|
| 266 |
@staticmethod
|
| 267 |
-
def _load_yaml_config_from_file(path=
|
| 268 |
"""
|
| 269 |
Load the YAML configuration
|
| 270 |
"""
|
| 271 |
config = {}
|
| 272 |
try:
|
| 273 |
-
with open(path,
|
| 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(
|
| 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[
|
| 309 |
-
base_url = grobid_config[
|
| 310 |
-
action_url = base_url + grobid_config[
|
| 311 |
|
| 312 |
return action_url
|
| 313 |
|
| 314 |
-
def process_texts(self, input, method_name=
|
| 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[
|
| 332 |
return self.process_texts(input, method_name, params, headers)
|
| 333 |
elif status != 200:
|
| 334 |
-
print(
|
| 335 |
return status, None
|
| 336 |
else:
|
| 337 |
return status, json.loads(res.text)
|
| 338 |
|
| 339 |
-
def process_text(self, input, method_name=
|
| 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[
|
| 357 |
return self.process_text(input, method_name, params, headers)
|
| 358 |
elif status != 200:
|
| 359 |
-
print(
|
| 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[
|
| 383 |
return self.process_text(input, method_name, params, headers)
|
| 384 |
elif status != 200:
|
| 385 |
-
print(
|
| 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[
|
| 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[
|
| 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[
|
| 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
|
| 41 |
-
st.session_state[
|
| 42 |
|
| 43 |
-
if
|
| 44 |
-
st.session_state[
|
| 45 |
|
| 46 |
-
if
|
| 47 |
-
st.session_state[
|
| 48 |
|
| 49 |
-
if
|
| 50 |
-
st.session_state[
|
| 51 |
|
| 52 |
-
if
|
| 53 |
-
st.session_state[
|
| 54 |
|
| 55 |
-
if
|
| 56 |
-
st.session_state[
|
| 57 |
|
| 58 |
-
if
|
| 59 |
-
st.session_state[
|
| 60 |
if os.path.exists("revision.txt"):
|
| 61 |
-
with open("revision.txt",
|
| 62 |
from_file = fr.read()
|
| 63 |
-
st.session_state[
|
| 64 |
|
| 65 |
if "messages" not in st.session_state:
|
| 66 |
st.session_state.messages = []
|
| 67 |
|
| 68 |
-
if
|
| 69 |
-
st.session_state[
|
| 70 |
|
| 71 |
-
if
|
| 72 |
-
st.session_state[
|
| 73 |
|
| 74 |
-
if
|
| 75 |
-
st.session_state[
|
| 76 |
|
| 77 |
-
if
|
| 78 |
-
st.session_state[
|
| 79 |
|
| 80 |
-
if
|
| 81 |
-
st.session_state[
|
| 82 |
|
| 83 |
-
if
|
| 84 |
-
st.session_state[
|
| 85 |
|
| 86 |
-
if
|
| 87 |
-
st.session_state[
|
| 88 |
|
| 89 |
-
if
|
| 90 |
-
st.session_state[
|
| 91 |
|
| 92 |
-
if
|
| 93 |
-
st.session_state[
|
| 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 |
-
|
| 102 |
-
|
| 103 |
-
|
| 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[
|
| 129 |
-
st.session_state[
|
| 130 |
-
st.session_state[
|
| 131 |
-
st.session_state[
|
| 132 |
-
if st.session_state[
|
| 133 |
-
st.session_state[
|
| 134 |
|
| 135 |
|
| 136 |
def clear_memory():
|
| 137 |
"""Clear the conversation buffer memory (chat history)."""
|
| 138 |
-
st.session_state[
|
| 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[
|
| 154 |
-
|
| 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[
|
| 191 |
|
| 192 |
materials_client = NERClientGeneric(ping=True)
|
| 193 |
config_materials = {
|
| 194 |
-
|
| 195 |
-
"server": os.environ[
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
# 'processText_disable_linking': "/service/process/text"
|
| 201 |
-
}
|
| 202 |
}
|
| 203 |
}
|
| 204 |
|
| 205 |
materials_client.set_config(config_materials)
|
| 206 |
|
| 207 |
-
gqa = GrobidAggregationProcessor(
|
| 208 |
-
|
|
|
|
| 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[
|
| 234 |
-
for message in st.session_state[
|
| 235 |
-
if message[
|
| 236 |
-
container.chat_message("user").markdown(message[
|
| 237 |
-
elif message[
|
| 238 |
if mode == "LLM":
|
| 239 |
-
container.chat_message("assistant").markdown(message[
|
| 240 |
else:
|
| 241 |
-
container.chat_message("assistant").write(message[
|
| 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[
|
| 255 |
"Model:",
|
| 256 |
options=API_MODELS.keys(),
|
| 257 |
-
index=(list(API_MODELS.keys())).index(
|
| 258 |
-
|
|
|
|
| 259 |
placeholder="Select model",
|
| 260 |
help="Select the LLM model:",
|
| 261 |
-
disabled=st.session_state[
|
| 262 |
)
|
| 263 |
|
| 264 |
-
st.session_state[
|
| 265 |
"Embeddings:",
|
| 266 |
options=API_EMBEDDINGS.keys(),
|
| 267 |
-
index=(list(API_EMBEDDINGS.keys())).index(
|
| 268 |
-
|
| 269 |
-
|
| 270 |
placeholder="Select embedding",
|
| 271 |
help="Select the Embedding function:",
|
| 272 |
-
disabled=st.session_state[
|
| 273 |
)
|
| 274 |
|
| 275 |
-
api_key = os.environ[
|
| 276 |
|
| 277 |
-
if model not in st.session_state[
|
| 278 |
with st.spinner("Preparing environment"):
|
| 279 |
-
st.session_state[
|
| 280 |
-
st.session_state[
|
| 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[
|
| 292 |
-
|
| 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 |
-
|
| 322 |
-
|
| 323 |
)
|
| 324 |
-
st.session_state[
|
| 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[
|
| 329 |
"Identify materials and properties.",
|
| 330 |
-
help=
|
| 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(
|
| 338 |
-
|
| 339 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
if chunk_size == -1:
|
| 341 |
-
context_size = st.slider(
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
else:
|
| 345 |
-
context_size = st.slider(
|
| 346 |
-
|
| 347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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[
|
| 357 |
-
st.markdown(
|
| 358 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
|
| 360 |
if uploaded_file and not st.session_state.loaded_embeddings:
|
| 361 |
-
if model not in st.session_state[
|
| 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(
|
| 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[
|
| 376 |
|
| 377 |
-
st.session_state[
|
| 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[
|
| 386 |
st.session_state.messages = []
|
| 387 |
except GrobidServiceError as exc:
|
| 388 |
-
st.session_state[
|
| 389 |
-
st.session_state[
|
| 390 |
-
st.session_state[
|
| 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(
|
| 422 |
-
|
|
|
|
| 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[
|
| 436 |
messages.chat_message(message["role"]).markdown(message["content"], unsafe_allow_html=True)
|
| 437 |
-
elif message[
|
| 438 |
messages.chat_message(message["role"]).write(message["content"])
|
| 439 |
-
elif message[
|
| 440 |
messages.chat_message(message["role"]).markdown(message["content"], unsafe_allow_html=True)
|
| 441 |
-
if model not in st.session_state[
|
| 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[
|
| 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[
|
| 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[
|
| 466 |
-
question,
|
| 467 |
-
st.session_state.doc_id,
|
| 468 |
-
context_size=context_size
|
| 469 |
)
|
| 470 |
|
| 471 |
-
annotations = [
|
| 472 |
-
|
|
|
|
|
|
|
| 473 |
gradients = generate_color_gradient(len(annotations))
|
| 474 |
for i, color in enumerate(gradients):
|
| 475 |
for annotation in annotations[i]:
|
| 476 |
-
annotation[
|
| 477 |
if i == 0:
|
| 478 |
-
annotation[
|
| 479 |
|
| 480 |
-
st.session_state[
|
| 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[
|
| 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[
|
| 504 |
with st.container(height=600):
|
| 505 |
pdf_viewer(
|
| 506 |
-
input=st.session_state[
|
| 507 |
annotation_outline_size=2,
|
| 508 |
-
annotations=st.session_state[
|
| 509 |
render_text=True,
|
| 510 |
-
scroll_to_annotation=1
|
| 511 |
-
|
|
|
|
| 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 |
)
|