DEV Community

Sinai Rivera
Sinai Rivera

Posted on

πŸ’Š AI Drug Side Effect Tracker Automated Weekly Monitoring

πŸ”¬ 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

architecture-diagram

🏷️ 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.

  1. Open Amazon Bedrock in us-east-1 β†’ Playgrounds β†’ Chat.
  2. Click Select model β†’ Amazon β†’ Nova Micro β†’ Apply.

step-0-1a-select-model

  1. Send the prompt:
In one sentence, what is topical fluorouracil 5% cream used for?
Enter fullscreen mode Exit fullscreen mode

step-0-1-nova-micro

βœ… 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.

  1. Open Amazon S3 β†’ Create bucket.
  2. Name: efudex-tracker-reports-sr-2026 (bucket names must be globally unique, so add your own suffix).
  3. Keep Block all public access enabled so the reports stay private.
  4. Leave the other settings as default and click Create bucket.

step-0-2-s3-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.

  1. Open Amazon SNS β†’ Topics β†’ Create topic.
  2. Type: Standard (FIFO topics do not support email). Name: efudex-tracker-alerts.
  3. Create subscription β†’ Protocol: Email β†’ enter your email.
  4. Confirm the subscription from your inbox.
  5. Check that the subscription status is Confirmed.

step-0-3-sns-confirmed

  1. Send a test with Publish message (Subject: Test, Message: Hello from the Efudex tracker).

step-0-4-test-email

βœ… 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.

  1. Open AWS Lambda (us-east-1) β†’ Create function β†’ Author from scratch.
  2. Function name: efudex-tracker.
  3. Runtime: Python 3.14 (the latest version available).
  4. 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).

step-1-1a-create-form

  1. Click Create function.

step-1-1b-lambda-created

βœ… 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:

  1. Searches PubMed for articles added in the last few days that mention fluorouracil or Efudex, topical use or cream, and a skin-related term.
  2. 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.

  1. In the function page, open the Code tab.
  2. Replace the sample code in lambda_function.py with:
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
Enter fullscreen mode Exit fullscreen mode
  1. Click Deploy.

step-1-2-code-deployed

βœ… 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.

  1. 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.

  1. Open the Test tab and create an event named search-90-days:
{"days": 90}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ 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.

  1. Click Test.

step-1-3-test-result

πŸ“Š 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.

  1. In the function, open Configuration β†’ Permissions and click the Execution role name. This opens IAM.
  2. 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"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode
  1. Next β†’ Policy name: allow-nova-micro β†’ Create policy.

step-2-1-bedrock-permission

πŸ’‘ 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:

  1. Sends the title and abstract to Nova Micro, with instructions that define each label.
  2. Receives a label, the reaction, an exact quote from the abstract, and a short reason.
  3. 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
Enter fullscreen mode Exit fullscreen mode

In the Lambda console, replace all the code in lambda_function.py with the full version for this step:

πŸ“„ Full code for Step 2
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": "...", "formulation": "...", "reaction": "...", "evidence_quote": "...", "reason": "..."}"""


# ---------- PubMed (Step 1) ----------
def get(url):
    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):
    params = urllib.parse.urlencode({
        "db": "pubmed", "term": QUERY, "datetype": "edat",
        "reldate": days, "retmax": 100, "retmode": "json",
    })
    data = json.loads(get(f"{EUTILS}/esearch.fcgi?{params}"))
    return data["esearchresult"]["idlist"]


def fetch_articles(pmids):
    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_el = item.find(".//ArticleTitle")
        title = "".join(title_el.itertext()) if title_el 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


# ---------- 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


# ---------- Main ----------
def lambda_handler(event, context):
    days = int(event.get("days", 7))
    articles = fetch_articles(search_pubmed(days))

    results = []
    for article in articles:
        try:
            analysis = classify(article)
        except Exception as error:  # one bad article must not stop the whole run
            analysis = {"label": "needs_review", "formulation": "", "reaction": "",
                        "evidence_quote": "", "reason": f"Analysis error: {error}"}
        results.append({
            "pmid": article["pmid"],
            "title": article["title"],
            "url": article["url"],
            **analysis,
        })

    summary = {
        "days_searched": days,
        "articles_found": len(results),
        "counts": {label: sum(1 for r in results if r["label"] == label) for label in LABELS},
        "results": results,
    }
    print(json.dumps(summary, indent=2))
    return summary
Enter fullscreen mode Exit fullscreen mode

Click Deploy.

step-2-2-ai-code-deployed

βœ… Result: The function now sends each article to Nova Micro and validates the answer.

2.3 Test the AI Classification

🎯 What we are doing: Running the function again with the same 90-day window, this time with Nova Micro reading and classifying each of the 8 articles.

  1. Open the Test tab, select the saved search-90-days event, and click Test.

step-2-3a-counts

πŸ“Š Actual result:

Label Articles
βšͺ not_relevant 6
🟒 expected 1
🟑 notable 0
πŸ”΄ potentially_serious 1
πŸ” needs_review 0

The AI analysis of all 8 articles took about 5 seconds.

The most important finding was a case report of bilateral auricular necrosis after prolonged self-application of topical 5-FU. Nova Micro classified it as potentially_serious, and the code confirmed that its evidence quote appears in the abstract:

step-2-3b-example

πŸ‘©β€βš•οΈ Human review

To check whether the AI was discarding too much, the 8 results were reviewed manually (titles and the model's reasoning):

Article AI label Review
Atypical fibroxanthoma (case report) βšͺ not_relevant βœ… 5-FU mentioned, no reaction reported
Pretreatments for photodynamic therapy (review) βšͺ not_relevant βœ… 5-FU used as a tool, no reaction reported
Baicalin-loaded gel (lab study) βšͺ not_relevant βœ… About a different drug
Non-surgical therapies in older adults (review) βšͺ not_relevant βœ… General review, no case reported
Hydrogel for hypertrophic scars (lab study) βšͺ not_relevant βœ… Different formulation, preclinical
4% 5-FU cream in a person living with HIV 🟒 expected ⚠️ Reaction correctly classified, but 4%, not 5%
Conjunctival squamous cell carcinoma βšͺ not_relevant βœ… Eye use, not skin
Bilateral auricular necrosis πŸ”΄ potentially_serious βœ… Correct, and the key finding

⚠️ Note on specificity: The instructions asked the model to focus "especially" on 5% cream, which is not a strict rule. As a result, a report about 4% fluorouracil cream was included. The model did what it was told, not what was intended. The formulation field shows the concentration (4% cream), so a reviewer can spot it easily.

This lab keeps the instructions as they are. In a production version, the prompt could be refined, for example by sending any concentration other than 5% to needs_review.

βœ… Step 2 result: Nova Micro classifies each article with supporting evidence, and the code verifies every quote before accepting it.


3️⃣ Step 3: Save Reports to S3

🎯 What we are doing: So far, results only appear in the Lambda test console and disappear when it is closed. In this step, the function saves two files to the S3 bucket on every run:

  • JSON: machine-readable, useful for connecting the results to other tools.
  • CSV: opens in Excel, with a link to each article for manual verification.

3.1 Allow the Function to Write Reports

🎯 What we are doing: Adding a second inline policy so the function can save files to the bucket.

  1. Open the function's execution role in IAM β†’ Add permissions β†’ Create inline policy β†’ JSON:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "WriteReportsOnly",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::efudex-tracker-reports-sr-2026/reports/*"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode
  1. Policy name: allow-s3-write-reports β†’ Create policy.

step-3-1-s3-permission

πŸ’‘ Why write-only: The function can only write files, and only inside the reports/ folder. It cannot read, list, or delete anything, so a bug could never erase previous reports or touch other files.

βœ… Result: The role now has three policies: logging, allow-nova-micro, and allow-s3-write-reports.

3.2 Add the Report Code

🎯 What we are doing: Replacing the function code with a version that keeps the PubMed search and AI classification, and adds a save_reports function that writes both files to S3.

πŸ’‘ Why one folder per date: Reports are saved as reports/YYYY-MM-DD/report.json and report.csv, so every weekly run keeps its own report and weeks can be compared.

πŸ’‘ Why sort by severity: Articles are ordered from potentially_serious to not_relevant, so the most important findings are always at the top of the CSV.

New in this step:

import csv
import io
from datetime import datetime, timezone

BUCKET = "efudex-tracker-reports-sr-2026"  # change to your bucket name
SEVERITY_ORDER = ["potentially_serious", "notable", "needs_review", "expected", "not_relevant"]
s3 = boto3.client("s3")

# ---------- Reports in S3 (Step 3) ----------
def save_reports(summary):
    """Save the results as JSON and CSV in s3://BUCKET/reports/YYYY-MM-DD/."""
    folder = f"reports/{summary['run_date']}"

    json_key = f"{folder}/report.json"
    s3.put_object(
        Bucket=BUCKET, Key=json_key,
        Body=json.dumps(summary, indent=2, ensure_ascii=False).encode("utf-8"),
        ContentType="application/json",
    )

    columns = ["label", "pmid", "title", "formulation", "reaction",
               "evidence_quote", "reason", "url"]
    buffer = io.StringIO()
    writer = csv.DictWriter(buffer, fieldnames=columns, extrasaction="ignore")
    writer.writeheader()
    writer.writerows(summary["results"])

    csv_key = f"{folder}/report.csv"
    s3.put_object(
        Bucket=BUCKET, Key=csv_key,
        Body=buffer.getvalue().encode("utf-8-sig"),  # utf-8-sig so Excel shows special characters correctly
        ContentType="text/csv",
    )
    return [json_key, csv_key]

# In lambda_handler: sort by severity, then save the reports
results.sort(key=lambda r: SEVERITY_ORDER.index(r["label"]))
summary["report_files"] = save_reports(summary)
Enter fullscreen mode Exit fullscreen mode

In the Lambda console, replace all the code in lambda_function.py with the full version for this step:

πŸ“„ Full code for Step 3
import csv
import io
import json
import re
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timezone

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"]
BUCKET = "efudex-tracker-reports-sr-2026"  # change to your bucket name

# Order used in reports: most important first
SEVERITY_ORDER = ["potentially_serious", "notable", "needs_review", "expected", "not_relevant"]

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")
s3 = boto3.client("s3")

# ---------- 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": "..."}"""


# ---------- PubMed (Step 1) ----------
def get(url):
    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):
    params = urllib.parse.urlencode({
        "db": "pubmed", "term": QUERY, "datetype": "edat",
        "reldate": days, "retmax": 100, "retmode": "json",
    })
    data = json.loads(get(f"{EUTILS}/esearch.fcgi?{params}"))
    return data["esearchresult"]["idlist"]


def fetch_articles(pmids):
    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_el = item.find(".//ArticleTitle")
        title = "".join(title_el.itertext()) if title_el 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


# ---------- 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


# ---------- Reports in S3 (Step 3) ----------
def save_reports(summary):
    """Save the results as JSON and CSV in s3://BUCKET/reports/YYYY-MM-DD/."""
    folder = f"reports/{summary['run_date']}"

    json_key = f"{folder}/report.json"
    s3.put_object(
        Bucket=BUCKET, Key=json_key,
        Body=json.dumps(summary, indent=2, ensure_ascii=False).encode("utf-8"),
        ContentType="application/json",
    )

    columns = ["label", "pmid", "title", "formulation", "reaction",
               "evidence_quote", "reason", "url"]
    buffer = io.StringIO()
    writer = csv.DictWriter(buffer, fieldnames=columns, extrasaction="ignore")
    writer.writeheader()
    writer.writerows(summary["results"])

    csv_key = f"{folder}/report.csv"
    s3.put_object(
        Bucket=BUCKET, Key=csv_key,
        Body=buffer.getvalue().encode("utf-8-sig"),  # utf-8-sig so Excel shows special characters correctly
        ContentType="text/csv",
    )
    return [json_key, csv_key]


# ---------- Main ----------
def lambda_handler(event, context):
    days = int(event.get("days", 7))
    articles = fetch_articles(search_pubmed(days))

    results = []
    for article in articles:
        try:
            analysis = classify(article)
        except Exception as error:  # one bad article must not stop the whole run
            analysis = {"label": "needs_review", "formulation": "", "reaction": "",
                        "evidence_quote": "", "reason": f"Analysis error: {error}"}
        results.append({
            "pmid": article["pmid"],
            "title": article["title"],
            "url": article["url"],
            **analysis,
        })

    # Most important articles first
    results.sort(key=lambda r: SEVERITY_ORDER.index(r["label"]))

    summary = {
        "run_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
        "days_searched": days,
        "articles_found": len(results),
        "counts": {label: sum(1 for r in results if r["label"] == label) for label in LABELS},
        "results": results,
    }
    summary["report_files"] = save_reports(summary)

    print(json.dumps(summary, indent=2))
    return summary
Enter fullscreen mode Exit fullscreen mode

Click Deploy.

step-3-2-save-code-deployed

3.3 Test and Open the Report

🎯 What we are doing: Running the function and confirming both files are saved in S3, then opening the CSV in Excel.

  1. Run the search-90-days test event. The response now ends with report_files:

step-3-3a-test-report-files

  1. Open the bucket in Amazon S3 β†’ reports/ β†’ today's date folder:

step-3-3b-s3-files

  1. Select report.csv β†’ Download β†’ open it in Excel:

step-3-3c-csv-excel

The necrosis case appears in the first row, with its evidence quote and PubMed link, ready for manual verification.

βœ… Step 3 result: Every run saves a JSON and a CSV report in S3, sorted by severity, with a link to each article.


4️⃣ Step 4: Send the Email Summary with SNS

🎯 What we are doing: Having the function email the weekly summary through the SNS topic created in Step 0.3: articles sorted by severity, each with its reaction, evidence quote, and PubMed link, so every result can be verified manually.

4.1 Allow the Function to Publish to SNS

🎯 What we are doing: Adding a third inline policy so the function can publish to the efudex-tracker-alerts topic, and only to that topic.

  1. Open the function's execution role in IAM β†’ Add permissions β†’ Create inline policy β†’ JSON:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublishWeeklySummary",
      "Effect": "Allow",
      "Action": "sns:Publish",
      "Resource": "arn:aws:sns:us-east-1:<YOUR_ACCOUNT_ID>:efudex-tracker-alerts"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode
  1. Policy name: allow-sns-publish β†’ Create policy.

step-4-1-sns-permission

βœ… Result: The role has four policies, each limited to exactly what the function needs:

Policy Allows
AWSLambdaBasicExecutionRole Writing logs to CloudWatch
allow-nova-micro Calling Nova Micro only
allow-s3-write-reports Writing to reports/ only
allow-sns-publish Publishing to one SNS topic only

4.2 Add the Email Code

🎯 What we are doing: Replacing the function code with a version that keeps everything from Steps 1 to 3 and adds a send_email function.

πŸ’‘ Design choices:

  • Every article includes its PubMed link, including not_relevant ones, so a person can verify that nothing important was discarded.
  • The subject line summarizes what matters (for example, Efudex Tracker: 1 potentially serious, 0 notable (8 articles)), so urgent findings are visible from the inbox.
  • An email is sent even when no articles are found. Silence would be ambiguous: it could mean "nothing new" or "the function failed".

New in this step:

TOPIC_ARN = "arn:aws:sns:us-east-1:<YOUR_ACCOUNT_ID>:efudex-tracker-alerts"  # change to your topic ARN
sns = boto3.client("sns", region_name="us-east-1")

# ---------- Email with SNS (Step 4) ----------
SECTION_TITLES = {
    "potentially_serious": "πŸ”΄ POTENTIALLY SERIOUS",
    "notable": "🟑 NOTABLE",
    "needs_review": "πŸ” NEEDS REVIEW",
    "expected": "🟒 EXPECTED",
    "not_relevant": "βšͺ NOT RELEVANT",
}


def send_email(summary):
    """Send a plain-text summary with a link to every article."""
    counts = summary["counts"]
    lines = [
        "AI Drug Side Effect Tracker – Weekly Report",
        f"Run date: {summary['run_date']} | Period: last {summary['days_searched']} days",
        f"Articles found: {summary['articles_found']}",
        "",
    ]

    if summary["articles_found"] == 0:
        lines += ["No new articles matched the search this period.", ""]

    for label in SEVERITY_ORDER if summary["articles_found"] else []:
        group = [r for r in summary["results"] if r["label"] == label]
        lines.append(f"{SECTION_TITLES[label]} ({len(group)})")
        for r in group:
            lines.append(f"- {r['title']}")
            if label != "not_relevant":
                lines.append(f"  Reaction: {r.get('reaction', '')}")
                lines.append(f"  Formulation: {r.get('formulation', '')}")
                if r.get("evidence_quote"):
                    lines.append(f'  Evidence: "{r["evidence_quote"]}"')
                if label == "needs_review":
                    lines.append(f"  Reason: {r.get('reason', '')}")
            lines.append(f"  Link: {r['url']}")
        lines.append("")

    lines += [
        f"Reports saved in S3: s3://{BUCKET}/reports/{summary['run_date']}/",
        "",
        "This is an educational prototype. Every result must be verified by a person.",
    ]

    subject = (f"Efudex Tracker: {counts['potentially_serious']} potentially serious, "
               f"{counts['notable']} notable ({summary['articles_found']} articles)")
    sns.publish(TopicArn=TOPIC_ARN, Subject=subject[:100], Message="\n".join(lines))

# In lambda_handler, after saving the reports
send_email(summary)
Enter fullscreen mode Exit fullscreen mode

In the Lambda console, replace all the code in lambda_function.py with the final version, available in the Complete Code section at the end of this article.

Click Deploy.

step-4-2-email-code-deployed

4.3 Test the Email

🎯 What we are doing: Running the complete workflow for the first time: search PubMed, classify with Nova Micro, save the reports to S3, and send the email.

  1. Run the search-90-days test event.
  2. Check the inbox of the subscribed email address.

The subject line shows the key numbers before the email is even opened:

step-4-3a-email-inbox

The body lists every article by severity, with its reaction, evidence quote, and PubMed link:

step-4-3b-email-body

Clicking the link of the necrosis case opens its PubMed page, where the evidence quote can be found in the abstract with Ctrl + F.

πŸ’‘ Note on consistency: The labels were identical to Step 2.3 (6 / 1 / 0 / 1 / 0). However, a free-text field changed slightly: the formulation of the necrosis case was reported as topical 5-FU in Step 2.3 and as topical in this run. Even with temperature: 0, model wording can vary between runs. This is another reason to rely on fixed labels for decisions and keep free text for human context.

βœ… Step 4 result: The full workflow runs end to end, and the weekly summary arrives by email with a verifiable link for every article.


5️⃣ Step 5: Weekly Schedule with EventBridge Scheduler

🎯 What we are doing: Until now, the function only ran when the Test button was clicked. In this step, Amazon EventBridge Scheduler wakes it up every Monday at 8:00 AM (New York time) and tells it to search the last 7 days. From then on, the only thing left to do is read the email.

πŸ’‘ EventBridge vs. EventBridge Scheduler: Both live in the same console, but they solve different problems.

EventBridge (rules) EventBridge Scheduler
Triggered by Something that happens (an event) A time (a schedule)
Analogy A doorbell An alarm clock
Time zones UTC only for scheduled rules Any time zone, e.g., America/New_York
One-time runs No Yes

Scheduler was chosen because it supports time zones (8:00 AM stays 8:00 AM after daylight saving time changes) and one-time schedules, which are used below for testing.

πŸ’‘ Cost: EventBridge Scheduler includes 14 million free invocations per month. This lab uses 4 or 5.

5.1 Create the Weekly Schedule

  1. Open Amazon EventBridge β†’ Scheduler β†’ Schedules β†’ Create schedule.
  2. Name: efudex-tracker-weekly.
  3. Recurring schedule β†’ Cron-based schedule:
Minutes Hours Day of month Month Day of the week Year
0 8 ? * MON *
  1. Flexible time window: Off. Time zone: America/New_York.

step-5-1a-schedule-pattern

  1. Target: AWS Lambda – Invoke β†’ efudex-tracker, with the payload:
{"days": 7}
Enter fullscreen mode Exit fullscreen mode
  1. Permissions: Create new role for this schedule. This role can only invoke the function.
  2. Create schedule.

step-5-1b-schedule-created

5.2 Test the Schedule Without Waiting Until Monday

🎯 What we are doing: Creating a one-time schedule a few minutes in the future, with the same target and payload. When it fires, the function runs on its own, without clicking Test.

  1. Create schedule β†’ Name: efudex-tracker-test-once β†’ One-time schedule: today at 12:00, America/New_York, flexible time window Off.
  2. Target: efudex-tracker, payload {"days": 7}.
  3. Permissions: Use existing role β†’ the role created in 5.1 (no need for a second role).

step-5-2a-test-schedule

At 12:00 PM, the email arrived on its own:

step-5-2b-scheduled-email

No articles had been added to PubMed in the last 7 days, so the email says so explicitly. This is the design choice from Step 4.2 in action: an empty week still produces an email, which proves the function ran.

The Lambda Monitor tab confirms an invocation at the same time, with no manual test:

step-5-2c-lambda-monitor

The chart uses UTC: the point at 17:00 UTC is the scheduled run at 12:00 PM New York time. The earlier points are the manual tests from Steps 1 to 4 (5 invocations in total). This is the same time zone difference that EventBridge Scheduler handles for us.

βœ… Step 5 result: The tracker now runs automatically every Monday at 8:00 AM New York time.


6️⃣ Step 6: Cleanup

🎯 What we are doing: Deleting every resource created in this lab, so nothing keeps running or generating costs.

πŸ’‘ Why the order matters: Delete what triggers the function first (the schedule), and what stores data last (S3 and SNS), so nothing runs while resources are being removed.

# Service What to delete Note
1 EventBridge Scheduler efudex-tracker-weekly and efudex-tracker-test-once Stops future runs
2 Lambda efudex-tracker
3 CloudWatch Logs /aws/lambda/efudex-tracker Log groups are not deleted with the function
4 IAM The function role and the Scheduler role Inline policies are deleted with the role
4 IAM AWSLambdaBasicExecutionRole-... policy Customer managed: not deleted with the role
5 S3 Empty, then delete the bucket A bucket must be empty to be deleted
6 SNS efudex-tracker-alerts topic and its subscription

Amazon Bedrock needs no cleanup: Nova Micro is billed per use, with nothing to turn off.

βœ… Step 6 result: All lab resources are removed.

πŸ“š 7. Lessons Learned and Next Steps

What worked

  • AWS Lambda + Amazon Bedrock turned a manual weekly literature search into an automated workflow, built entirely from the AWS Management Console.
  • Validating the AI's answer in code (the evidence quote must appear in the abstract) makes every reported finding traceable to the original text.
  • Least privilege was practical: four small policies, each allowing exactly one thing.
  • A serious case was found automatically: bilateral auricular necrosis after prolonged topical 5-FU use.

What I learned about AI

  • The model does what you ask, not what you mean. "Especially 5% cream" was not a strict rule, so a 4% cream report was included.
  • Fixed labels are stable; free text varies. Labels stayed identical across runs, but the wording of the formulation field changed slightly.

Limitations and next steps

Limitation found Possible improvement
Other concentrations (4%) are included Send any concentration other than 5% to needs_review
Two runs on the same day overwrite the report Add the time to the S3 folder name and use an S3 Lifecycle rule to keep, for example, 30 days of history
Only PubMed abstracts are analyzed The original article of the necrosis case, published in The Permanente Journal, includes clinical images that the abstract does not. Full text and other sources add context
PubMed is the only source Sources like Embase could be added in a pharmacovigilance setting, subject to licensing and AI-use terms
A failed week is not recovered Store the date of the last successful run and search from there

This prototype does not replace human review. It organizes the weekly search so a person can verify every result through its PubMed link.

πŸ“¦ 8. Complete Code

Final lambda_function.py (Python 3.14). Replace BUCKET and <YOUR_ACCOUNT_ID> with your own values.

import csv
import io
import json
import re
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timezone

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"]
BUCKET = "efudex-tracker-reports-sr-2026"  # change to your bucket name
TOPIC_ARN = "arn:aws:sns:us-east-1:<YOUR_ACCOUNT_ID>:efudex-tracker-alerts"  # change to your topic ARN

# Order used in reports: most important first
SEVERITY_ORDER = ["potentially_serious", "notable", "needs_review", "expected", "not_relevant"]

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")
s3 = boto3.client("s3")
sns = boto3.client("sns", 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": "..."}"""


# ---------- PubMed (Step 1) ----------
def get(url):
    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):
    params = urllib.parse.urlencode({
        "db": "pubmed", "term": QUERY, "datetype": "edat",
        "reldate": days, "retmax": 100, "retmode": "json",
    })
    data = json.loads(get(f"{EUTILS}/esearch.fcgi?{params}"))
    return data["esearchresult"]["idlist"]


def fetch_articles(pmids):
    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_el = item.find(".//ArticleTitle")
        title = "".join(title_el.itertext()) if title_el 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


# ---------- 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


# ---------- Reports in S3 (Step 3) ----------
def save_reports(summary):
    """Save the results as JSON and CSV in s3://BUCKET/reports/YYYY-MM-DD/."""
    folder = f"reports/{summary['run_date']}"

    json_key = f"{folder}/report.json"
    s3.put_object(
        Bucket=BUCKET, Key=json_key,
        Body=json.dumps(summary, indent=2, ensure_ascii=False).encode("utf-8"),
        ContentType="application/json",
    )

    columns = ["label", "pmid", "title", "formulation", "reaction",
               "evidence_quote", "reason", "url"]
    buffer = io.StringIO()
    writer = csv.DictWriter(buffer, fieldnames=columns, extrasaction="ignore")
    writer.writeheader()
    writer.writerows(summary["results"])

    csv_key = f"{folder}/report.csv"
    s3.put_object(
        Bucket=BUCKET, Key=csv_key,
        Body=buffer.getvalue().encode("utf-8-sig"),  # utf-8-sig so Excel shows special characters correctly
        ContentType="text/csv",
    )
    return [json_key, csv_key]


# ---------- Email with SNS (Step 4) ----------
SECTION_TITLES = {
    "potentially_serious": "πŸ”΄ POTENTIALLY SERIOUS",
    "notable": "🟑 NOTABLE",
    "needs_review": "πŸ” NEEDS REVIEW",
    "expected": "🟒 EXPECTED",
    "not_relevant": "βšͺ NOT RELEVANT",
}


def send_email(summary):
    """Send a plain-text summary with a link to every article."""
    counts = summary["counts"]
    lines = [
        "AI Drug Side Effect Tracker – Weekly Report",
        f"Run date: {summary['run_date']} | Period: last {summary['days_searched']} days",
        f"Articles found: {summary['articles_found']}",
        "",
    ]

    if summary["articles_found"] == 0:
        lines += ["No new articles matched the search this period.", ""]

    for label in SEVERITY_ORDER if summary["articles_found"] else []:
        group = [r for r in summary["results"] if r["label"] == label]
        lines.append(f"{SECTION_TITLES[label]} ({len(group)})")
        for r in group:
            lines.append(f"- {r['title']}")
            if label != "not_relevant":
                lines.append(f"  Reaction: {r.get('reaction', '')}")
                lines.append(f"  Formulation: {r.get('formulation', '')}")
                if r.get("evidence_quote"):
                    lines.append(f'  Evidence: "{r["evidence_quote"]}"')
                if label == "needs_review":
                    lines.append(f"  Reason: {r.get('reason', '')}")
            lines.append(f"  Link: {r['url']}")
        lines.append("")

    lines += [
        f"Reports saved in S3: s3://{BUCKET}/reports/{summary['run_date']}/",
        "",
        "This is an educational prototype. Every result must be verified by a person.",
    ]

    subject = (f"Efudex Tracker: {counts['potentially_serious']} potentially serious, "
               f"{counts['notable']} notable ({summary['articles_found']} articles)")
    sns.publish(TopicArn=TOPIC_ARN, Subject=subject[:100], Message="\n".join(lines))


# ---------- Main ----------
def lambda_handler(event, context):
    days = int(event.get("days", 7))
    articles = fetch_articles(search_pubmed(days))

    results = []
    for article in articles:
        try:
            analysis = classify(article)
        except Exception as error:  # one bad article must not stop the whole run
            analysis = {"label": "needs_review", "formulation": "", "reaction": "",
                        "evidence_quote": "", "reason": f"Analysis error: {error}"}
        results.append({
            "pmid": article["pmid"],
            "title": article["title"],
            "url": article["url"],
            **analysis,
        })

    # Most important articles first
    results.sort(key=lambda r: SEVERITY_ORDER.index(r["label"]))

    summary = {
        "run_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
        "days_searched": days,
        "articles_found": len(results),
        "counts": {label: sum(1 for r in results if r["label"] == label) for label in LABELS},
        "results": results,
    }
    summary["report_files"] = save_reports(summary)
    send_email(summary)

    print(json.dumps(summary, indent=2))
    return summary
Enter fullscreen mode Exit fullscreen mode

Top comments (0)