Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/src/routes/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const AlertSchema = z
state: z.string().optional(),
district: z.string().optional(),
reported_at: z.string().optional(),
proof_image_url: z.string().optional().nullable(),
})
.passthrough();

Expand Down
1 change: 1 addition & 0 deletions apps/ml/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ locust>=2.31.8
# Agentic Orchestration and PDF rendering
langgraph>=0.1.4
pymupdf>=1.24.0
cloudinary>=1.36.0

# ScispaCy for Medical NER
scispacy>=0.5.4
Expand Down
7 changes: 1 addition & 6 deletions apps/ml/routers/asr.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,11 +657,7 @@ def _trim_audio_buffer(self) -> None:
self.audio_buffer = self.audio_buffer[samples_to_trim:]
self.buffer_start_seconds += samples_to_trim / STREAM_SAMPLE_RATE

<<<<<<< HEAD
def _build_response(self, transcript: str, *, run_ner: bool = False) -> dict:
base: dict = {
=======
def _build_response(self, transcript: str) -> dict[str, str | float | bool | None]:
medicine_db = get_medicine_database_list()
fuzzy_match = get_phonetic_fuzzy_match(transcript, medicine_db)

Expand All @@ -674,8 +670,7 @@ def _build_response(self, transcript: str) -> dict[str, str | float | bool | Non
suggestion_applied = True
message = f"Showing results for {corrected_name} β€” did you mean this?"

return {
>>>>>>> pr-1840
base: dict = {
"transcript": transcript,
"corrected_name": corrected_name,
"suggestion_applied": suggestion_applied,
Expand Down
49 changes: 49 additions & 0 deletions apps/ml/services/alert_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class AlertInfo(BaseModel):
state: Optional[str] = Field(description="The state mentioned")
district: Optional[str] = Field(description="The district mentioned")
reported_at: Optional[str] = Field(description="The date of the alert in YYYY-MM-DD if possible")
proof_image_url: Optional[str] = Field(default=None, description="The Cloudinary URL of the original document snapshot proof")

class AlertList(BaseModel):
alerts: List[AlertInfo] = Field(description="List of alerts extracted from the text")
Expand Down Expand Up @@ -124,6 +125,7 @@ def extract_alerts_from_pdf_images(pdf_bytes: bytes) -> List[Dict[str, Any]]:
"""
Extracts structured alert information from scanned/image-based PDFs
by rendering pages to PNG images and analyzing them with Gemini-1.5-Flash.
Also uploads the page screenshots to Cloudinary to serve as audit proof.
"""
if not LANGCHAIN_AVAILABLE:
logging.error("LangChain dependencies missing.")
Expand All @@ -135,6 +137,29 @@ def extract_alerts_from_pdf_images(pdf_bytes: bytes) -> List[Dict[str, Any]]:
logging.warning("GOOGLE_API_KEY not set. Cannot extract alerts using Gemini.")
return []

# Configure Cloudinary if credentials are available
cloudinary_configured = False
cloud_name = os.getenv("CLOUDINARY_CLOUD_NAME")
api_key_cloud = os.getenv("CLOUDINARY_API_KEY")
api_secret_cloud = os.getenv("CLOUDINARY_API_SECRET")

if cloud_name and api_key_cloud and api_secret_cloud:
try:
import cloudinary
import cloudinary.uploader
cloudinary.config(
cloud_name=cloud_name,
api_key=api_key_cloud,
api_secret=api_secret_cloud,
secure=True
)
cloudinary_configured = True
logging.info("Cloudinary client configured successfully for CDSCO scraper proof archiving.")
except Exception as config_exc:
logging.warning(f"Failed to configure Cloudinary SDK: {config_exc}")
else:
logging.warning("Cloudinary environment variables missing, skipping proof uploads.")

# Use fitz (PyMuPDF) to render PDF pages
import fitz
import base64
Expand All @@ -159,6 +184,28 @@ def extract_alerts_from_pdf_images(pdf_bytes: bytes) -> List[Dict[str, Any]]:
png_bytes = pix.tobytes("png")
b64_image = base64.b64encode(png_bytes).decode("utf-8")

# Upload the rendered page snapshot to Cloudinary if configured
proof_image_url = None
if cloudinary_configured:
try:
import cloudinary.uploader
import time
timestamp = int(time.time())
public_id = f"cdsco_alert_page_{page_num + 1}_{timestamp}"

logging.info(f"Uploading page {page_num + 1} screenshot to Cloudinary as {public_id}...")
upload_res = cloudinary.uploader.upload(
png_bytes,
folder="sahidawa/cdsco_proofs",
public_id=public_id,
overwrite=True,
resource_type="image"
)
proof_image_url = upload_res.get("secure_url")
logging.info(f"Cloudinary upload success: {proof_image_url}")
except Exception as upload_exc:
logging.error(f"Cloudinary upload failed for page {page_num + 1}: {upload_exc}")

# Prepare the multimodal prompt
prompt_text = (
"You are an expert at extracting structured information from pharmaceutical recall and alert notices.\n"
Expand Down Expand Up @@ -205,6 +252,8 @@ def extract_alerts_from_pdf_images(pdf_bytes: bytes) -> List[Dict[str, Any]]:
)
if key not in seen_keys:
seen_keys.add(key)
# Ingest the Cloudinary proof URL
alert_dict["proof_image_url"] = proof_image_url
all_alerts.append(alert_dict)
except Exception as page_exc:
logging.error(f"Error extracting alerts from page {page_num + 1} with Gemini: {page_exc}")
Expand Down
18 changes: 16 additions & 2 deletions apps/ml/tests/test_alert_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

@patch("services.alert_extractor.ChatGoogleGenerativeAI")
@patch("fitz.open")
def test_extract_alerts_from_pdf_images_success(mock_fitz_open, mock_chat_llm):
@patch("cloudinary.uploader.upload")
def test_extract_alerts_from_pdf_images_success(mock_cloudinary_upload, mock_fitz_open, mock_chat_llm):
# Mock fitz document and pages
mock_doc = MagicMock()
mock_page = MagicMock()
Expand Down Expand Up @@ -37,13 +38,26 @@ def test_extract_alerts_from_pdf_images_success(mock_fitz_open, mock_chat_llm):
mock_alert_list = AlertList(alerts=[mock_alert_info])
mock_structured_llm.invoke.return_value = mock_alert_list

# Mock Cloudinary response
mock_cloudinary_upload.return_value = {"secure_url": "https://res.cloudinary.com/fake-url.png"}

# Run extractor
pdf_bytes = b"fake_pdf_content"
with patch.dict("os.environ", {"GOOGLE_API_KEY": "fake_key"}):
env_mock = {
"GOOGLE_API_KEY": "fake_key",
"CLOUDINARY_CLOUD_NAME": "fake_cloud",
"CLOUDINARY_API_KEY": "fake_api",
"CLOUDINARY_API_SECRET": "fake_secret"
}
with patch.dict("os.environ", env_mock):
alerts = extract_alerts_from_pdf_images(pdf_bytes)

assert len(alerts) == 1
assert alerts[0]["reported_brand_name"] == "Mock Brand"
assert alerts[0]["batch_number"] == "MB123"
assert alerts[0]["manufacturer"] == "Mock Pharma"
assert alerts[0]["alert_type"] == "NSQ"
assert alerts[0]["proof_image_url"] == "https://res.cloudinary.com/fake-url.png"

# Verify Cloudinary was called correctly
mock_cloudinary_upload.assert_called_once()
3 changes: 3 additions & 0 deletions apps/ml/tests/test_asr_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ def test_stream_returns_empty_final_when_stopped_before_audio():
assert final == {
"type": "final",
"transcript": "",
"corrected_name": "",
"suggestion_applied": False,
"message": None,
"language": None,
"languageConfidence": None,
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Add proof_image_url column to drug_alerts table
ALTER TABLE public.drug_alerts ADD COLUMN IF NOT EXISTS proof_image_url TEXT;
Loading