🔬 Project Overview
Only 15 days.
In the United States, when a pharmaceutical company learns about a serious and unexpected adverse reaction to one of its drugs, including one described in a published medical article, it has no more than 15 calendar days to report it to the FDA.
What if the weekly search ran on its own, and a person only had to verify the results?
This project explores how AI and AWS can automate the weekly monitoring of PubMed for new articles reporting skin reactions to topical fluorouracil 5% cream.
Every Monday, it:
- 🔎 Searches PubMed for new articles about skin reactions.
- 🧠 Uses AI to classify each reaction by severity, quoting the exact sentence from the abstract as evidence.
- 🗂️ Saves JSON and CSV reports.
- 📧 Emails a summary with a link to every article, so each result can be checked in seconds
This is an educational prototype. It does not establish drug causality or provide clinical recommendations.
⚡ 1. TL;DR
A beginner-friendly AWS lab that checks PubMed every week, analyzes new abstracts with Amazon Bedrock (Nova Micro), stores reports in Amazon S3, and emails a summary through Amazon SNS. Everything is built from the AWS Management Console.
🏷️ 2. Quick Metadata
| Field | Description |
|---|---|
| 🚧 Status | Laboratory / Prototype |
| 🎓 Difficulty | Beginner |
| 💊 Medication | Fluorouracil topical cream 5% |
| 📚 Source | PubMed (NCBI E-utilities) |
| 🧠 AI Model | Amazon Bedrock – Nova Micro |
| ☁️ AWS Services | Lambda, Bedrock, S3, SNS, EventBridge Scheduler |
| 📍 Region | us-east-1 |
| 📅 Frequency | Mondays, 8:00 AM ET |
| 📊 Reports | JSON and CSV |
🔬 3. Why This Project Matters
- 🔄 No manual searches: New PubMed articles are reviewed automatically every week.
- ⚖️ Expected vs. serious: Redness and crusting are the expected effect; the system separates these from reactions that deserve attention.
- 🔎 Traceability: Every classification includes a quote from the abstract.
- 👩⚕️ Human review: Unclear cases are flagged for a person to check.
🏗️ 4. System Architecture
🏷️ Classification Labels
| Label | Meaning |
|---|---|
⚪ not_relevant | Not about topical fluorouracil skin reactions |
🟢 expected | Usual local effects (redness, crusting, burning) |
🟡 notable | Beyond the usual effect (e.g., allergic dermatitis) |
🔴 potentially_serious | Severe (e.g., ulceration, infection, hospitalization) |
🔍 needs_review | Unclear formulation or no supporting quote |
📋 5. Prerequisites
- ☁️ An AWS account (us-east-1)
- 📧 An email address
- 🌐 A web browser
No local Python or AWS CLI required.
🚀 6. Step-by-Step Implementation
| Step | Description |
|---|---|
| 0️⃣ | Setup: Bedrock access, S3 bucket, SNS topic |
| 1️⃣ | Search PubMed |
| 2️⃣ | Analyze abstracts with Nova Micro |
| 3️⃣ | Save reports to S3 |
| 4️⃣ | Send email with SNS |
| 5️⃣ | Weekly schedule |
| 6️⃣ | Cleanup |
0️⃣ Step 0: Setup
🎯 What we are doing: Preparing the three building blocks the system will use, before writing any code.
💡 Why: If one of these services does not work in the account, the code will fail later and the problem will be harder to find. Testing each service on its own first makes debugging much easier.
| Service | Role | Analogy |
|---|---|---|
| 🧠 Amazon Bedrock – Nova Micro | Reads and classifies each abstract | The brain |
| 🗂️ Amazon S3 | Stores the reports privately | The filing cabinet |
| 📧 Amazon SNS | Sends the email summary | The mail carrier |
All resources are created in us-east-1.
0.1 Test Nova Micro in the Bedrock Playground
🎯 What: Send a test question to Nova Micro from the Bedrock console.
💡 Why: Confirms the account can use the model before we call it from code. Amazon Bedrock now enables serverless models by default, so no manual model activation is needed, but verifying is better than assuming. Nova Micro was chosen because it is text-only and one of the lowest-cost models, which fits a task that only reads abstracts.
- Open Amazon Bedrock in us-east-1 → Playgrounds → Chat.
- Click Select model → Amazon → Nova Micro → Apply.
- Send the prompt:
In one sentence, what is topical fluorouracil 5% cream used for? ✅ Nova Micro responds, so the account can use it.
0.2 Create the S3 Bucket
🎯 What: Create a private bucket (a folder in the cloud).
💡 Why: Every Monday the system generates JSON and CSV reports. S3 keeps them stored, organized by date, and private.
- Open Amazon S3 → Create bucket.
- Name:
efudex-tracker-reports-sr-2026(bucket names must be globally unique, so add your own suffix). - Keep Block all public access enabled so the reports stay private.
- Leave the other settings as default and click Create bucket.
✅ The private bucket is ready to store the weekly reports.
0.3 Create the SNS Topic and Email Subscription
🎯 What: Create an SNS topic and subscribe an email address to it.
💡 Why: The results should arrive automatically, without logging in to AWS. A topic works like a mailbox: anything the code publishes to it is delivered to every subscribed email. SNS was chosen over Amazon SES because it only requires confirming one email, with no sandbox setup.
- Open Amazon SNS → Topics → Create topic.
- Type: Standard (FIFO topics do not support email). Name:
efudex-tracker-alerts. - Create subscription → Protocol: Email → enter your email.
- Confirm the subscription from your inbox.
- Check that the subscription status is Confirmed.
- Send a test with Publish message (Subject:
Test, Message:Hello from the Efudex tracker).
✅ Step 0 result: Nova Micro responds, the private S3 bucket exists, and SNS delivers email.
1️⃣ Step 1: Search PubMed
🎯 What we are doing: Creating an AWS Lambda function that searches PubMed for new articles about topical fluorouracil.
💡 Why: Lambda runs code in the cloud only when triggered, with no server to manage and no computer left running. In this step the function only searches PubMed; AI, reports, and email are added in later steps. Building one piece at a time makes it clear where a problem comes from if something fails.
1.1 Create the Lambda Function
🎯 What we are doing: Creating the Lambda function that will act as the "robot" of this lab. For now it is empty: it is the container where we will add the code that searches PubMed, calls the AI, saves the reports, and sends the email.
- Open AWS Lambda (us-east-1) → Create function → Author from scratch.
- Function name:
efudex-tracker. - Runtime: Python 3.14 (the latest version available).
- Execution role: keep the default, Create a new role with basic Lambda permissions.
💡 Why the default role: For now, the function can only write logs to Amazon CloudWatch. Permissions for Bedrock, S3, and SNS are added later, only when each one is needed (least privilege).
- Click Create function.
✅ Result: The efudex-tracker function exists and is ready for code.
1.2 Add the PubMed Search Code
🎯 What we are doing: Giving the function its first skill: searching PubMed. The code does two things:
- Searches PubMed for articles added in the last few days that mention fluorouracil or Efudex, topical use or cream, and a skin-related term.
- Downloads the title and abstract of each article, which the AI will read in Step 2.
💡 Why PubMed E-utilities: NCBI provides a free API to search PubMed from code, with no key required. The code only uses Python's standard library, so nothing needs to be installed or packaged for Lambda.
💡 Why a broad search: The query intentionally returns more than just Efudex 5% (for example, other concentrations). It is better to bring in extra articles and let the AI discard them in Step 2 than to miss a relevant case.
💡 Why the Entrez date (edat): It filters by the date the article entered PubMed, not its publication date, which is often months earlier or missing. This is what "new this week" really means for monitoring.
- In the function page, open the Code tab.
- Replace the sample code in
lambda_function.pywith:
import json import urllib.parse import urllib.request import xml.etree.ElementTree as ET # PubMed E-utilities base URL (free NCBI API, no key required) EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" # Search: fluorouracil/Efudex + topical/cream + skin-related terms, in title or abstract QUERY = ( '("fluorouracil"[tiab] OR "5-fluorouracil"[tiab] OR "5-FU"[tiab] OR "Efudex"[tiab]) ' 'AND (topical[tiab] OR cream[tiab]) ' 'AND (skin[tiab] OR cutaneous[tiab] OR dermatitis[tiab] OR reaction*[tiab] OR adverse[tiab])' ) def get(url): """Download a URL and return its text.""" req = urllib.request.Request(url, headers={"User-Agent": "efudex-tracker-lab"}) with urllib.request.urlopen(req, timeout=30) as resp: return resp.read().decode("utf-8") def search_pubmed(days): """Return the PMIDs of articles added to PubMed in the last `days` days.""" params = urllib.parse.urlencode({ "db": "pubmed", "term": QUERY, "datetype": "edat", # Entrez date: when the article entered PubMed "reldate": days, "retmax": 100, "retmode": "json", }) data = json.loads(get(f"{EUTILS}/esearch.fcgi?{params}")) return data["esearchresult"]["idlist"] def fetch_articles(pmids): """Download the title and abstract of each PMID.""" if not pmids: return [] params = urllib.parse.urlencode({ "db": "pubmed", "id": ",".join(pmids), "retmode": "xml", }) root = ET.fromstring(get(f"{EUTILS}/efetch.fcgi?{params}")) articles = [] for item in root.findall(".//PubmedArticle"): pmid = item.findtext(".//PMID") title = "".join(item.find(".//ArticleTitle").itertext()) if item.find(".//ArticleTitle") is not None else "" abstract = " ".join("".join(p.itertext()) for p in item.findall(".//AbstractText")) articles.append({ "pmid": pmid, "title": title.strip(), "abstract": abstract.strip(), "url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/", }) return articles def lambda_handler(event, context): days = int(event.get("days", 7)) # default: last 7 days pmids = search_pubmed(days) articles = fetch_articles(pmids) summary = { "days_searched": days, "articles_found": len(articles), "articles_without_abstract": sum(1 for a in articles if not a["abstract"]), "articles": [ {"pmid": a["pmid"], "title": a["title"], "url": a["url"], "abstract_length": len(a["abstract"])} for a in articles ], } print(json.dumps(summary, indent=2)) return summary - Click Deploy.
✅ Result: The function can search PubMed. It is tested in the next step.
1.3 Test the Search
🎯 What we are doing: Running the function for the first time to confirm it finds real articles in PubMed.
Before testing, increase the timeout. By default, Lambda stops a function after 3 seconds, which is too short for PubMed and, later, for the AI.
- Configuration → General configuration → Edit → Timeout: 5 min 0 sec → Save.
💡 Why 5 minutes does not increase cost: The timeout is a safety limit, not what you pay for. Lambda bills only for the time the function actually runs.
- Open the Test tab and create an event named
search-90-days:
{"days": 90} 💡 Why 90 days: The weekly run searches the last 7 days, which may return zero articles. A 90-day window guarantees real results for testing, without too many to review.
- Click Test.
📊 Actual result:
| Metric | Value |
|---|---|
| Articles found (last 90 days) | 8 |
| Articles without abstract | 0 |
| Execution time | ~0.7 seconds |
| Memory used | 53 MB of 128 MB |
The first result was a case report on atypical fibroxanthoma, which is not necessarily about an Efudex skin reaction. This confirms the search is broad on purpose: deciding which articles are truly relevant is the AI's job in Step 2.
✅ Step 1 result: The Lambda function searches PubMed and downloads abstracts in under one second.
2️⃣ Step 2: Analyze Abstracts with Nova Micro
🎯 What we are doing: Connecting the Lambda function to Amazon Bedrock so Nova Micro reads each abstract and classifies it. This step has three parts: grant permission, add the AI code, and test the classification.
💡 From chat to API: In Step 0.1, we talked to Nova Micro by typing in the Bedrock Playground. Here, the same model is called by code through the Bedrock API. This is how AI is used in real applications: not from a chat window, but from code that runs automatically, at scale, and without a person present.
| Bedrock Playground (Step 0.1) | Lambda + Bedrock API (Step 2) | |
|---|---|---|
| Who talks to the model | A person typing | Code |
| Volume | One question at a time | Every new article, every week |
| Purpose | Verify access and test prompts | Run the actual workflow |
The Playground remains useful for testing instructions (prompts) before putting them in code.
2.1 Allow the Function to Use Nova Micro
🎯 What we are doing: In AWS, a service cannot use another service without explicit permission. The function's execution role currently only allows writing logs, so we add a permission to call Nova Micro.
- In the function, open Configuration → Permissions and click the Execution role name. This opens IAM.
- Add permissions → Create inline policy → switch to JSON and paste:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "InvokeNovaMicroOnly", "Effect": "Allow", "Action": "bedrock:InvokeModel", "Resource": "arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-micro-v1:0" } ] } - Next → Policy name:
allow-nova-micro→ Create policy.
💡 Why only Nova Micro: The policy allows one action on one model. The AWS managed policy AmazonBedrockFullAccess would have been faster to attach, but it grants access to every Bedrock model and feature, which goes against least privilege.
💡 Why an inline policy:
| Policy type | Best for |
|---|---|
| AWS managed | Quick starts; usually broader than needed |
| Customer managed | Reusing one policy across many roles |
| Inline | A permission that belongs to a single role |
The permission belongs only to this function, and an inline policy is deleted together with the role during cleanup, so nothing is left behind. In larger projects, customer managed policies are often preferred because they are easier to reuse and audit.
✅ Result: The role now has two policies: the default logging policy and allow-nova-micro.
2.2 Add the AI Classification Code
🎯 What we are doing: Replacing the function code with a new version that keeps the PubMed search from Step 1 and adds the AI. For each article, the function:
- Sends the title and abstract to Nova Micro, with instructions that define each label.
- Receives a label, the reaction, an exact quote from the abstract, and a short reason.
- Validates the answer before accepting it.
💡 Why the code does not blindly trust the AI: Language models can make things up. If the model reports a reaction but its quote does not appear word for word in the abstract, the code changes the label to needs_review so a person checks it. Invalid labels and analysis errors are also routed to needs_review, and one failed article never stops the whole run.
💡 Why temperature: 0: It makes the model's answers as consistent as possible, so the same abstract gets the same classification on every run.
💡 Why the Converse API: It is Bedrock's standard way to call any chat model with the same request format, so switching from Nova Micro to another model only requires changing MODEL_ID.
New in this step (the PubMed code from Step 1 stays the same):
import re import boto3 MODEL_ID = "amazon.nova-micro-v1:0" LABELS = ["not_relevant", "expected", "notable", "potentially_serious", "needs_review"] bedrock = boto3.client("bedrock-runtime", region_name="us-east-1") # ---------- Instructions for the AI ---------- SYSTEM_PROMPT = """You screen PubMed abstracts for skin reactions to TOPICAL fluorouracil (5-FU), especially 5% cream (Efudex). Choose exactly one label: - not_relevant: not about topical fluorouracil, or no skin reaction to it is reported (e.g., systemic/IV 5-FU, other drugs, efficacy only). - expected: usual local effects within the treated area (redness, crusting, burning, itching, erosion). - notable: beyond the usual effect (e.g., allergic contact dermatitis, spread beyond the treated area, pigment changes). - potentially_serious: severe outcomes (e.g., ulceration, necrosis, secondary infection, hospitalization, SJS/TEN, systemic toxicity). - needs_review: the formulation or route is unclear, or the abstract does not give enough information to decide. Rules: - Use only the abstract. Do not use outside knowledge about the patient. - evidence_quote must be copied WORD FOR WORD from the abstract (one sentence or less). Use "" if the label is not_relevant. - Answer with JSON only, no other text: {"label": "...", "formulation": "...", "reaction": "...", "evidence_quote": "...", "reason": "..."}""" # ---------- AI classification (Step 2) ---------- def normalize(text): """Lowercase and collapse spaces, so small formatting differences don't matter.""" return re.sub(r"\s+", " ", text).strip().lower() def parse_json(text): """Extract the JSON object from the model's answer.""" match = re.search(r"\{.*\}", text, re.DOTALL) return json.loads(match.group(0)) def classify(article): if not article["abstract"]: return {"label": "needs_review", "formulation": "", "reaction": "", "evidence_quote": "", "reason": "No abstract available."} response = bedrock.converse( modelId=MODEL_ID, system=[{"text": SYSTEM_PROMPT}], messages=[{"role": "user", "content": [{"text": f"Title: {article['title']}\n\nAbstract: {article['abstract']}"}]}], inferenceConfig={"maxTokens": 400, "temperature": 0}, ) answer = response["output"]["message"]["content"][0]["text"] result = parse_json(answer) # Safety checks: the code does not blindly trust the AI. if result.get("label") not in LABELS: result["reason"] = f"Invalid label from model: {result.get('label')}" result["label"] = "needs_review" quote = result.get("evidence_quote", "") if result["label"] in ("expected", "notable", "potentially_serious"): if not quote or normalize(quote) not in normalize(article["abstract"]): result["reason"] = "Evidence quote not found in the abstract. " + result.get("reason", "") result["label"] = "needs_review" return result In the Lambda console, replace all the code in lambda_function.py with the full version for this step:
import json import re import urllib.parse import urllib.request import xml.etree.ElementTree as ET import boto3 # ---------- Settings ---------- EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" MODEL_ID = "amazon.nova-micro-v1:0" LABELS = ["not_relevant", "expected", "notable", "potentially_serious", "needs_review"] QUERY = ( '("fluorouracil"[tiab] OR "5-fluorouracil"[tiab] OR "5-FU"[tiab] OR "Efudex"[tiab]) ' 'AND (topical[tiab] OR cream[tiab]) ' 'AND (skin[tiab] OR cutaneous[tiab] OR dermatitis[tiab] OR reaction*[tiab] OR adverse[tiab])' ) bedrock = boto3.client("bedrock-runtime", region_name="us-east-1") # ---------- Instructions for the AI ---------- SYSTEM_PROMPT = """You screen PubMed abstracts for skin reactions to TOPICAL fluorouracil (5-FU), especially 5% cream (Efudex). Choose exactly one label: - not_relevant: not about topical fluorouracil, or no skin reaction to it is reported (e.g., systemic/IV 5-FU, other drugs, efficacy only). - expected: usual local effects within the treated area (redness, crusting, burning, itching, erosion). - notable: beyond the usual effect (e.g., allergic contact dermatitis, spread beyond the treated area, pigment changes). - potentially_serious: severe outcomes (e.g., ulceration, necrosis, secondary infection, hospitalization, SJS/TEN, systemic toxicity). - needs_review: the formulation or route is unclear, or the abstract does not give enough information to decide. Rules: - Use only the abstract. Do not use outside knowledge about the patient. - evidence_quote must be copied WORD FOR WORD from the abstract (one sentence or less). Use "" if the label is not_relevant. - Answer with JSON only, no other text: {"label"...










