Skip to content

Commit 0d0843f

Browse files
authored
Handle grobid connection failures (#43)
This PR extended the work from @Sanakhamassi in #35
1 parent a8a96f9 commit 0d0843f

5 files changed

Lines changed: 270 additions & 44 deletions

File tree

document_qa/document_qa_engine.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class TextMerger:
3434
3535
Args:
3636
model_name: A tiktoken model name (e.g. ``"gpt-4"``). When given,
37-
the tokenizer for that model is used.
37+
the tokenizer for that model is used.
3838
encoding_name: A tiktoken encoding name (default ``"gpt2"``).
3939
Ignored when *model_name* is provided.
4040
"""
@@ -174,7 +174,7 @@ class DataStorage:
174174
175175
Args:
176176
embedding_function: A LangChain-compatible ``Embeddings`` instance
177-
root_path: Optional directory for persisted embeddings.
177+
root_path: Optional directory for persisted embeddings.
178178
engine: The vector-store class to use.
179179
180180
"""
@@ -278,7 +278,7 @@ class DocumentQAEngine:
278278
Args:
279279
llm: A LangChain chat model (e.g. ``ChatOpenAI``).
280280
data_storage: A `DataStorage` instance for managing embeddings.
281-
grobid_url: URL of the GROBID server.
281+
grobid_url: URL of the GROBID server.
282282
memory: Optional ``ConversationBufferMemory`` for multi-turn context.
283283
284284
"""
@@ -297,7 +297,8 @@ def __init__(self,
297297
llm,
298298
data_storage: DataStorage,
299299
grobid_url=None,
300-
memory=None
300+
memory=None,
301+
ping_grobid_server: bool = True
301302
):
302303

303304
self.llm = llm
@@ -307,7 +308,7 @@ def __init__(self,
307308
self.data_storage = data_storage
308309

309310
if grobid_url:
310-
self.grobid_processor = GrobidProcessor(grobid_url)
311+
self.grobid_processor = GrobidProcessor(grobid_url, ping_server=ping_grobid_server)
311312

312313
def query_document(
313314
self,
@@ -317,7 +318,7 @@ def query_document(
317318
context_size=4,
318319
extraction_schema=None,
319320
verbose=False
320-
) -> tuple[Any, str]:
321+
) -> tuple[Any, str, list]:
321322
"""Ask a question and get an LLM-generated answer.
322323
323324
Retrieves the most relevant chunks from the vector store, feeds
@@ -354,7 +355,7 @@ def query_document(
354355

355356
if output_parser:
356357
try:
357-
return self._parse_json(response, output_parser), response
358+
return self._parse_json(response, output_parser), response, coordinates
358359
except Exception as oe:
359360
print("Failing to parse the response", oe)
360361
return None, response, coordinates
@@ -369,7 +370,7 @@ def query_document(
369370
else:
370371
return None, response, coordinates
371372

372-
def query_storage(self, query: str, doc_id, context_size=4) -> tuple[List[Document], list]:
373+
def query_storage(self, query: str, doc_id, context_size=4) -> tuple[List[str], list]:
373374
"""Retrieve relevant text passages without calling the LLM.
374375
375376
Useful for debugging which chunks would be used as context, or for
@@ -480,7 +481,7 @@ def _parse_json(self, response, output_parser):
480481

481482
return parsed_output
482483

483-
def _run_query(self, doc_id, query, context_size=4) -> tuple[List[Document], list]:
484+
def _run_query(self, doc_id, query, context_size=4) -> tuple[Any, list]:
484485
relevant_documents, relevant_document_coordinates = self._get_context(doc_id, query, context_size)
485486
response = self.chain.invoke({"context": relevant_documents, "question": query})
486487
return response, relevant_document_coordinates
@@ -550,7 +551,7 @@ def get_text_from_document(self, pdf_file_path, chunk_size=-1, perc_overlap=0.1,
550551
biblio['filename'] = filename.replace(" ", "_")
551552

552553
if verbose:
553-
print("Generating embeddings for:", hash, ", filename: ", filename)
554+
print("Generating embeddings for filename: ", filename)
554555

555556
texts = []
556557
metadatas = []

document_qa/grobid_processors.py

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,19 @@
2020

2121
import dateparser
2222
import grobid_tei_xml
23+
import requests
2324
from bs4 import BeautifulSoup
2425
from grobid_client.grobid_client import GrobidClient
2526

2627

28+
class GrobidServiceError(RuntimeError):
29+
"""Raised when the Grobid service fails to process a document."""
30+
31+
def __init__(self, message="Grobid service error", status_code=None):
32+
super().__init__(message)
33+
self.status_code = status_code
34+
35+
2736
def get_span_start(type, title=None):
2837
"""Return an opening ``<span>`` tag for an annotation of the given *type*."""
2938
title_ = ' title="' + title + '"' if title is not None else ""
@@ -168,22 +177,61 @@ def process_structure(self, input_path, coordinates=False):
168177
169178
Returns ``None`` if GROBID returns a non-200 status.
170179
"""
171-
pdf_file, status, text = self.grobid_client.process_pdf("processFulltextDocument",
172-
input_path,
173-
consolidate_header=True,
174-
consolidate_citations=False,
175-
segment_sentences=False,
176-
tei_coordinates=coordinates,
177-
include_raw_citations=False,
178-
include_raw_affiliations=False,
179-
generateIDs=True)
180+
try:
181+
pdf_file, status, text = self.grobid_client.process_pdf("processFulltextDocument",
182+
input_path,
183+
consolidate_header=True,
184+
consolidate_citations=False,
185+
segment_sentences=False,
186+
tei_coordinates=coordinates,
187+
include_raw_citations=False,
188+
include_raw_affiliations=False,
189+
generateIDs=True)
190+
except requests.exceptions.RequestException as exc:
191+
# Transport-level failure (connection refused, timeout, …).
192+
# Local/usage errors (bad path, parsing bugs) are intentionally
193+
# not caught here so they surface with their real traceback.
194+
raise GrobidServiceError("Grobid service did not respond.") from exc
180195

181196
if status != 200:
182-
return
197+
# Grobid attaches a human-readable reason to error responses
198+
# (e.g. a 500 body explaining what went wrong). Surface it
199+
# alongside the status code instead of discarding it.
200+
reason = text.strip() if text else ""
201+
message = f"Grobid service returned status {status}."
202+
if reason:
203+
message += f" {reason}"
204+
raise GrobidServiceError(message, status_code=status)
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.
215+
try:
216+
document_object = self.parse_grobid_xml(text, coordinates=coordinates)
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
183224

184-
document_object = self.parse_grobid_xml(text, coordinates=coordinates)
185225
document_object['filename'] = Path(pdf_file).stem.replace(".tei", "")
186226

227+
# Well-formed XML can still carry no usable text (e.g. an image-only or
228+
# truncated PDF). Nothing to embed downstream, so fail loudly here.
229+
if not any(passage.get('text', '').strip() for passage in document_object.get('passages', [])):
230+
raise GrobidServiceError(
231+
"Grobid returned a document with no extractable text.",
232+
status_code=status
233+
)
234+
187235
return document_object
188236

189237
def process_single(self, input_file):
@@ -221,7 +269,7 @@ def parse_grobid_xml(self, text, coordinates=False):
221269
try:
222270
year = dateparser.parse(doc_biblio.header.date).year
223271
biblio["publication_year"] = year
224-
except:
272+
except Exception:
225273
pass
226274

227275
output_data['biblio'] = biblio

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Grobid
22
grobid-quantities-client==0.4.0
3-
grobid-client-python==0.0.9
3+
grobid-client-python==0.1.4
44
grobid-tei-xml==0.1.3
55

66
# Utils
@@ -30,6 +30,6 @@ typing-inspect==0.9.0
3030
typing_extensions==4.12.2
3131
pydantic==2.10.6
3232
sentence-transformers==2.6.1
33-
streamlit-pdf-viewer==0.0.25
33+
streamlit-pdf-viewer==0.0.29
3434
umap-learn==0.5.6
3535
plotly==5.20.0

streamlit_app.py

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,19 @@
1515
from tempfile import NamedTemporaryFile
1616

1717
import dotenv
18+
import streamlit as st
1819
from grobid_quantities.quantities import QuantitiesAPI
1920
from langchain.memory import ConversationBufferMemory
2021
from langchain_openai import ChatOpenAI
2122
from streamlit_pdf_viewer import pdf_viewer
2223

2324
from document_qa.custom_embeddings import ModalEmbeddings
25+
from document_qa.document_qa_engine import DocumentQAEngine, DataStorage
26+
from document_qa.grobid_processors import GrobidAggregationProcessor, decorate_text_with_annotations, GrobidServiceError
2427
from document_qa.ner_client_generic import NERClientGeneric
2528

2629
dotenv.load_dotenv(override=True)
2730

28-
import streamlit as st
29-
from document_qa.document_qa_engine import DocumentQAEngine, DataStorage
30-
from document_qa.grobid_processors import GrobidAggregationProcessor, decorate_text_with_annotations
31-
3231
API_MODELS = {
3332
"microsoft/Phi-4-mini-instruct": os.environ["PHI_URL"],
3433
"Qwen/Qwen3-0.6B": os.environ["QWEN_URL"]
@@ -169,7 +168,13 @@ def init_qa(model_name, embeddings_name):
169168
)
170169

171170
storage = DataStorage(embeddings)
172-
return DocumentQAEngine(chat, storage, grobid_url=os.environ['GROBID_URL'], memory=st.session_state['memory'])
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+
)
173178

174179

175180
@st.cache_resource
@@ -358,19 +363,36 @@ def play_old_messages(container):
358363
st.stop()
359364

360365
with left_column:
361-
with st.spinner('Reading file, calling Grobid, and creating in-memory embeddings...'):
362-
binary = uploaded_file.getvalue()
363-
tmp_file = NamedTemporaryFile()
364-
tmp_file.write(bytearray(binary))
365-
st.session_state['binary'] = binary
366-
367-
st.session_state['doc_id'] = hash = st.session_state['rqa'][model].create_memory_embeddings(
368-
tmp_file.name,
369-
chunk_size=chunk_size,
370-
perc_overlap=0.1
371-
)
372-
st.session_state['loaded_embeddings'] = True
373-
st.session_state.messages = []
366+
try:
367+
with st.spinner('Reading file, calling Grobid, and creating in-memory embeddings...'):
368+
binary = uploaded_file.getvalue()
369+
tmp_path = None
370+
try:
371+
with NamedTemporaryFile(suffix=".pdf", delete=False) as tmp_file:
372+
tmp_file.write(bytearray(binary))
373+
tmp_file.flush()
374+
tmp_path = tmp_file.name
375+
st.session_state['binary'] = binary
376+
377+
st.session_state['doc_id'] = st.session_state['rqa'][model].create_memory_embeddings(
378+
tmp_path,
379+
chunk_size=chunk_size,
380+
perc_overlap=0.1
381+
)
382+
finally:
383+
if tmp_path and os.path.exists(tmp_path):
384+
os.unlink(tmp_path)
385+
st.session_state['loaded_embeddings'] = True
386+
st.session_state.messages = []
387+
except GrobidServiceError as exc:
388+
st.session_state['doc_id'] = None
389+
st.session_state['loaded_embeddings'] = False
390+
st.session_state['uploaded'] = False
391+
message = str(exc).strip() or "Grobid is not responding."
392+
if not message.endswith((".", "!", "?")):
393+
message += "."
394+
st.error(f"{message} Please try again later.")
395+
st.stop()
374396

375397

376398
def rgb_to_hex(rgb):

0 commit comments

Comments
 (0)