Skip to content

Commit 8876499

Browse files
authored
feat: Extract and populate support DB records (#19)
1 parent b0d07a0 commit 8876499

10 files changed

Lines changed: 495 additions & 2 deletions

File tree

app/Makefile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,12 @@ sleep-5:
218218

219219
help: ## Prints the help documentation and info about each command
220220
@grep -E '^[/a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
221+
222+
223+
##################################################
224+
# Application scripts
225+
##################################################
226+
227+
# Usage: make extract-supports NAME="SupportName" FILE="path/to/file"
228+
extract-supports:
229+
$(PY_RUN_CMD) extract-supports "$(NAME)" "$(FILE)"

app/docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ services:
3939
# For verifying client certificates
4040
- PHOENIX_TLS_VERIFY_CLIENT=False
4141
# In case we want to disable PII Redaction
42-
- REDACT_PII=${REDACT_PII}
42+
- REDACT_PII=${REDACT_PII:-True}
4343

4444
# See README.md to enable authentication locally
4545
# - PHOENIX_ENABLE_AUTH=True

app/poetry.lock

Lines changed: 21 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ presidio-analyzer = "^2.2.359"
3333
presidio-anonymizer = "^2.2.359"
3434
spacy = "^3.8.7"
3535
en-core-web-lg = {url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_lg-3.8.0/en_core_web_lg-3.8.0-py3-none-any.whl"}
36+
pypdf = "^6.0.0"
3637

3738
[tool.poetry.group.dev.dependencies]
3839
certifi = "^2025.8.3"
@@ -61,6 +62,7 @@ build-backend = "poetry.core.masonry.api"
6162
db-migrate = "src.db.migrations.run:up"
6263
db-migrate-down = "src.db.migrations.run:down"
6364
db-migrate-down-all = "src.db.migrations.run:downall"
65+
extract-supports= "src.ingestion.extract_supports:main"
6466

6567
[tool.black]
6668
line-length = 100

app/src/app_config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from functools import cached_property
2+
3+
from src.adapters import db
14
from src.util.env_config import PydanticBaseEnvConfig
25

36

@@ -14,5 +17,12 @@ class AppConfig(PydanticBaseEnvConfig):
1417

1518
redact_pii: bool = True
1619

20+
@cached_property
21+
def db_client(self) -> db.PostgresDBClient:
22+
return db.PostgresDBClient()
23+
24+
def db_session(self) -> db.Session:
25+
return self.db_client.get_session()
26+
1727

1828
config = AppConfig()

app/src/db/models/support_listing.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ class SupportListing(Base, IdMixin, TimestampMixin):
2424
comment="origin of the Support Listing; a file path or a website URL",
2525
)
2626

27+
supports: Mapped[list["Support"]] = relationship(
28+
"Support", back_populates="support_listing", cascade="all, delete"
29+
)
30+
2731

2832
class Support(Base, IdMixin, TimestampMixin):
2933
__tablename__ = "support"

app/src/ingestion/__init__.py

Whitespace-only changes.
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
"Add SupportListing and associated Support records to the DB by extracting file contents."
2+
3+
import argparse
4+
import asyncio
5+
import json
6+
import logging
7+
import os
8+
from pathlib import Path
9+
from pprint import pformat
10+
from tempfile import NamedTemporaryFile
11+
from typing import Iterable
12+
13+
from haystack import AsyncPipeline
14+
from haystack.components.builders import ChatPromptBuilder
15+
from haystack.components.converters import PyPDFToDocument
16+
from haystack.components.preprocessors import DocumentSplitter
17+
from haystack.dataclasses import Document
18+
from haystack.dataclasses.chat_message import ChatMessage
19+
from haystack_integrations.components.generators.amazon_bedrock import AmazonBedrockChatGenerator
20+
from pydantic import BaseModel, Field
21+
from smart_open import open as smart_open
22+
23+
from src.adapters import db
24+
from src.app_config import config
25+
from src.db.models.support_listing import Support, SupportListing
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
def extract_from_pdf(pdf_filepath: str) -> Document: # pragma: no cover
31+
if not os.path.exists(pdf_filepath):
32+
raise FileNotFoundError(f"File not found: {pdf_filepath}")
33+
34+
# There's also PDFMinerToDocument (for a different pdf extractor) and
35+
# MultiFileConverter (for variety of file types but requires more dependencies)
36+
converter = PyPDFToDocument()
37+
38+
# Since the converter only accept local files,
39+
# create a temporary file to hold the PDF data in case the file is not local
40+
with smart_open(pdf_filepath, "rb") as pdf_file:
41+
# Create temp file
42+
with NamedTemporaryFile(mode="wb") as tmpfile:
43+
tmpfile.write(pdf_file.read())
44+
temp_file = Path(tmpfile.name)
45+
46+
result = converter.run(sources=[temp_file])
47+
return result["documents"][0]
48+
49+
50+
def split_doc(doc: Document, passages_per_doc: int = 11, overlap: int = 1) -> list[Document]:
51+
"""
52+
Split document into multiple documents, each consisting of passages.
53+
A 'passage' is delimited by '\n\n'
54+
"""
55+
assert doc.content
56+
57+
# Remove leading/trailing whitespace from each line so that 'passage' splitting works
58+
doc.content = "\n".join(line.strip() for line in doc.content.splitlines())
59+
60+
# Split the document into "passages"
61+
splitter = DocumentSplitter(
62+
split_by="passage",
63+
split_length=passages_per_doc,
64+
split_overlap=overlap,
65+
)
66+
result = splitter.run(documents=[doc])
67+
return result["documents"]
68+
69+
70+
class SupportEntry(BaseModel):
71+
name: str
72+
website: str | None
73+
emails: list[str]
74+
addresses: list[str]
75+
phone_numbers: list[str]
76+
description: str | None = Field(description="2-sentence summary, including offerings")
77+
78+
79+
SYSTEM_PROMPT_TEMPLATE = f"""Using only the document content provided by the user, return a JSON list of objects.
80+
Each object must match exactly this schema:
81+
```
82+
{json.dumps(SupportEntry.model_json_schema(), indent=2)}
83+
```
84+
85+
Rules:
86+
- Output ONLY raw JSON (no markdown fences, no commentary).
87+
- If a field is missing in the PDF, use null or [] as appropriate.
88+
- Keep strings concise; avoid line breaks inside values.
89+
"""
90+
91+
USER_TEMPLATE = """Document content:
92+
{{ doc.content }}
93+
"""
94+
95+
96+
def create_llm() -> AmazonBedrockChatGenerator: # pragma: no cover
97+
# The max_tokens set in Haystack cannot exceed the maximum output token limit supported by the specific model configured on Amazon Bedrock.
98+
# Anthropic's Claude 3 Sonnet: Newer versions support up to 64k output tokens, but the actual usable limit on
99+
# Bedrock might differ based on the throughput settings
100+
return AmazonBedrockChatGenerator(
101+
model="us.anthropic.claude-3-5-sonnet-20241022-v2:0", generation_kwargs={"max_tokens": 8192}
102+
)
103+
104+
105+
def build_pipeline() -> AsyncPipeline:
106+
pipe = AsyncPipeline()
107+
108+
messages = [
109+
ChatMessage.from_system(SYSTEM_PROMPT_TEMPLATE),
110+
ChatMessage.from_user(USER_TEMPLATE),
111+
]
112+
pipe.add_component(
113+
"prompt_builder", ChatPromptBuilder(template=messages, required_variables="*")
114+
)
115+
pipe.add_component("llm", create_llm())
116+
# If needed, add OutputValidator to retry the LLM call -- https://haystack.deepset.ai/tutorials/28_structured_output_with_loop
117+
# Can update to use structured responses when https://github.com/deepset-ai/haystack/issues/8276 is complete
118+
119+
pipe.connect("prompt_builder", "llm")
120+
return pipe
121+
122+
123+
async def run_pipeline(pipeline: AsyncPipeline, doc: Document) -> list[dict]:
124+
assert doc.content
125+
logger.info("Running pipeline with subdoc content length: %d", len(doc.content))
126+
127+
_result = await pipeline.run_async({"prompt_builder": {"doc": doc}})
128+
assert len(_result["llm"]["replies"]) == 1
129+
reply = _result["llm"]["replies"][0]
130+
# Useful info for checking if tokens have reached the limit for the LLM
131+
logger.info("Finished pipeline with subdoc content length: %d", len(doc.content))
132+
logger.debug(pformat(reply.meta))
133+
134+
support_entries = json.loads(reply.text)
135+
logger.info("Number of support entries: %d", len(support_entries))
136+
logger.debug([entry["name"] for entry in support_entries])
137+
return support_entries
138+
139+
140+
async def run_pipeline_and_join_results(pipeline: AsyncPipeline, docs: list[Document]) -> dict:
141+
"Run a pipeline for each document in parallel and join the results"
142+
tasks = [run_pipeline(pipeline, doc) for doc in docs]
143+
results = await asyncio.gather(*tasks)
144+
all_results = [item for sublist in results for item in sublist]
145+
return {entry["name"]: entry for entry in all_results}
146+
147+
148+
def extract_support_entries(name: str, doc: Document) -> dict[str, SupportEntry]:
149+
# Lengthy document content results in incomplete LLM responses, so split document with some overlap
150+
# and make multiple calls to the LLM and merge the LLM JSON results, resolving any entries with the same name
151+
split_docs = split_doc(doc)
152+
logger.info(
153+
"Split into %d subdocs with lengths: %s",
154+
len(split_docs),
155+
[len(d.content) if d.content else 0 for d in split_docs],
156+
)
157+
158+
pipeline = build_pipeline()
159+
supports = asyncio.run(run_pipeline_and_join_results(pipeline, split_docs))
160+
logger.info("Total supports: %d", len(supports))
161+
support_entries = {name: SupportEntry(**data) for name, data in supports.items()}
162+
return support_entries
163+
164+
165+
def save_to_db(
166+
db_session: db.Session,
167+
support_listing: SupportListing,
168+
support_entries: Iterable[SupportEntry],
169+
) -> None:
170+
existing_listing = (
171+
db_session.query(SupportListing)
172+
.where(SupportListing.name == support_listing.name)
173+
.one_or_none()
174+
)
175+
if existing_listing:
176+
logger.info("Update existing SupportListing: %r", existing_listing.name)
177+
existing_listing.uri = support_listing.uri
178+
179+
logger.info("Deleting Support records associated with: %r", support_listing.name)
180+
db_session.query(Support).where(Support.support_listing_id == existing_listing.id).delete()
181+
else:
182+
logger.info("Adding new SupportListing: %r", support_listing.name)
183+
db_session.add(support_listing)
184+
# Flush the session to get the ID populated
185+
db_session.flush()
186+
187+
support_listing_id = existing_listing.id if existing_listing else support_listing.id
188+
assert support_listing_id
189+
# Populate support records
190+
for support in support_entries:
191+
support_record = Support(
192+
support_listing_id=support_listing_id,
193+
name=support.name,
194+
addresses=support.addresses,
195+
phone_numbers=support.phone_numbers,
196+
description=support.description,
197+
website=support.website,
198+
email_addresses=support.emails,
199+
)
200+
db_session.add(support_record)
201+
202+
203+
# To test:
204+
# Download Basic Needs Resource Guide.pdf https://drive.google.com/file/d/1u2LCOoJC7jpPUE6wsQ2ZdiNYaqTb5NzT/view?usp=sharing
205+
# make extract-supports NAME="Basic Needs Resources" FILE=Basic\ Needs\ Resource\ Guide.pdf
206+
def main() -> None: # pragma: no cover
207+
logging.basicConfig(format="%(levelname)s - %(name)s - %(message)s", level=logging.INFO)
208+
209+
parser = argparse.ArgumentParser()
210+
parser.add_argument("name")
211+
parser.add_argument("filepath")
212+
args = parser.parse_args()
213+
214+
doc = extract_from_pdf(args.filepath)
215+
assert doc.content
216+
logger.info("Extracted content length: %d", len(doc.content))
217+
extracted_supports = extract_support_entries(args.name, doc)
218+
219+
for support in extracted_supports.values():
220+
logger.info("Support: %r", support.name)
221+
222+
with config.db_session() as db_session, db_session.begin():
223+
support_listing = SupportListing(name=args.name, uri=args.filepath)
224+
save_to_db(db_session, support_listing, extracted_supports.values())
225+
226+
logger.info("Done")

app/tests/src/ingestion/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)