DEV Community

shashank ms
shashank ms

Posted on

Using LLMs in Biology: A Guide

Biologists generate a lot of unstructured text: bench notes, protocol deviations, and observation logs. I built a small agent that takes raw wet-lab notes and returns structured metadata, normalized entities, and a draft methods paragraph ready for an ELN or publication. It runs entirely against Oxlo.ai's API, and because the notes can get long, the flat per-request pricing keeps costs predictable no matter how verbose the input.

What you'll need

Step 1: Configure the Oxlo.ai client

Point the OpenAI SDK at Oxlo.ai's compatible endpoint and pick a model with strong instruction following. I use Llama 3.3 70B here because it handles long biology text reliably.

from openai import OpenAI
import json
import os

client = OpenAI(
    base_url="https://api.oxlo.ai/v1",
    api_key=os.getenv("OXLO_API_KEY", "YOUR_OXLO_API_KEY")
)

MODEL = "llama-3.3-70b"

Step 2: Define the system prompt

The system prompt constrains the model to act as a meticulous lab partner that extracts entities, normalizes names, and flags missing experimental details.

SYSTEM_PROMPT = """You are a computational biology assistant. Your job is to read raw wet-lab notes and produce structured JSON.

Rules:
1. Extract these fields: species, genes, cell_lines, assays, reagents, and a draft_methods_paragraph.
2. Normalize gene names to HGNC symbols where possible.
3. Flag any missing_controls or missing_sample_sizes.
4. Output ONLY valid JSON. No markdown fences, no commentary.

JSON schema:
{
  "species": ["Homo sapiens"],
  "genes": ["TP53", "BRCA1"],
  "cell_lines": ["HEK293T"],
  "assays": ["qPCR"],
  "reagents": [{"name": "Lipofectamine 3000", "vendor": "Thermo", "catalog": "L3000015"}],
  "missing_controls": ["no negative control for transfection"],
  "missing_sample_sizes": false,
  "draft_methods_paragraph": "string"
}
"""

Step 3: Build the extraction function

This helper sends the raw notes to Oxlo.ai and parses the JSON response. I set temperature low to keep the entity extraction deterministic.

def extract_biology_notes(raw_notes: str) -> dict:
    response = client.chat.completions.create(
        model=MODEL,
        temperature=0.1,
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": raw_notes},
        ],
    )

    content = response.choices[0].message.content.strip()
    # Strip markdown fences if the model accidentally adds them
    if content.startswith("

```"):
        lines = content.splitlines()
        if lines[0].startswith("```

"):
            lines = lines[1:]
        if lines and lines[-1].startswith("

```"):
            lines = lines[:-1]
        content = "\n".join(lines).strip()

    return json.loads(content)

Step 4: Add a validation layer

Before trusting the output, I run a second pass asking the model to verify the extracted entities against the original text and suggest any corrections. This catches hallucinated gene symbols. I switch to Kimi K2.6 for this reasoning step.

VALIDATION_PROMPT = """You are a careful reviewer. Given the original lab notes and the extracted JSON, verify that every gene, cell line, and reagent is actually mentioned or strongly implied in the original text.

Output valid JSON with two keys:
- "verified": true or false
- "corrections": [{"field": "genes", "old": "BRCA2", "new": "BRCA1", "reason": "notes mention BRCA1, not BRCA2"}]
"""

def validate_extraction(raw_notes: str, extraction: dict) -> dict:
    payload = json.dumps({"original_notes": raw_notes, "extraction": extraction}, indent=2)

    response = client.chat.completions.create(
        model="kimi-k2.6",
        temperature=0.1,
        messages=[
            {"role": "system", "content": VALIDATION_PROMPT},
            {"role": "user", "content": payload},
        ],
    )

    content = response.choices[0].message.content.strip()
    if content.startswith("```

"):
        lines = content.splitlines()
        if lines[0].startswith("

```"):
            lines = lines[1:]
        if lines and lines[-1].startswith("```

"):
            lines = lines[:-1]
        content = "\n".join(lines).strip()

    return json.loads(content)

Step 5: Wire everything into an agent class

I package the extraction and validation steps into a small agent class so I can call it with one method and get a clean report.

class BiologyNotesAgent:
    def __init__(self, client: OpenAI):
        self.client = client
        self.extraction_model = "llama-3.3-70b"
        self.validation_model = "kimi-k2.6"

    def process(self, raw_notes: str) -> dict:
        extraction = self._extract(raw_notes)
        validation = self._validate(raw_notes, extraction)
        return {
            "extraction": extraction,
            "validation": validation,
            "ready_for_eln": validation.get("verified", False) and not extraction.get("missing_controls")
        }

    def _extract(self, raw_notes: str) -> dict:
        response = self.client.chat.completions.create(
            model=self.extraction_model,
            temperature=0.1,
            messages=[
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": raw_notes},
            ],
        )
        return self._clean_json(response.choices[0].message.content)

    def _validate(self, raw_notes: str, extraction: dict) -> dict:
        payload = json.dumps({"original_notes": raw_notes, "extraction": extraction}, indent=2)
        response = self.client.chat.completions.create(
            model=self.validation_model,
            temperature=0.1,
            messages=[
                {"role": "system", "content": VALIDATION_PROMPT},
                {"role": "user", "content": payload},
            ],
        )
        return self._clean_json(response.choices[0].message.content)

    @staticmethod
    def _clean_json(text: str) -> dict:
        text = text.strip()
        if text.startswith("

```"):
            lines = text.splitlines()
            if lines[0].startswith("```

"):
                lines = lines[1:]
            if lines and lines[-1].startswith("

```

"):
                lines = lines[:-1]
            text = "\n".join(lines).strip()
        return json.loads(text)

Run it

Here is a realistic block of messy bench notes. I instantiate the agent and print the structured result.

RAW_NOTES = """
March 14 - started late because incubator alarm went off overnight. temp was 37.2 so probably ok.
Cells: used the HEK cells from passage 12, plated 6-well at 250k per well. not sure exact count, maybe 240k.
Transfection: mixed p53-gfp plasmid (addgene 12091) with lipo 3000 in opti-mem. ratio maybe 1:2.5? forgot to write down exactly.
Incubated o/n. next day looked green under scope. lots of debris though.
Harvested at 48h for western. used anti-p53 from santa cruz (sc-126). no loading control because ladder looked weird and i ran out of time.
qpcr later for b actin and p21. ran in triplicate but one well had air bubble so n=2 technically for that plate.
"""

agent = BiologyNotesAgent(client)
result = agent.process(RAW_NOTES)

print(json.dumps(result, indent=2))

Example output:

{
  "extraction": {
    "species": ["Homo sapiens"],
    "genes": ["TP53", "CDKN1A"],
    "cell_lines": ["HEK293"],
    "assays": ["transfection", "western blot", "qPCR"],
    "reagents": [
      {"name": "p53-gfp plasmid", "vendor": "Addgene", "catalog": "12091"},
      {"name": "Lipofectamine 3000", "vendor": null, "catalog": null},
      {"name": "Opti-MEM", "vendor": null, "catalog": null},
      {"name": "anti-p53", "vendor": "Santa Cruz Biotechnology", "catalog": "sc-126"}
    ],
    "missing_controls": ["no loading control for western blot"],
    "missing_sample_sizes": true,
    "draft_methods_paragraph": "HEK293 cells were plated at approximately 250,000 cells per well in a 6-well plate and transfected overnight with a p53-GFP plasmid (Addgene 12091) using Lipofectamine 3000 in Opti-MEM. After 48 hours, cells were harvested for western blot analysis using anti-p53 antibody (Santa Cruz sc-126). Gene expression was assessed by qPCR for ACTB and CDKN1A."
  },
  "validation": {
    "verified": true,
    "corrections": [
      {"field": "genes", "old": "B actin", "new": "ACTB", "reason": "standard HGNC symbol for beta-actin"}
    ]
  },
  "ready_for_eln": false
}

Wrap-up and next steps

This agent turns chaotic bench notes into structured records without manual templating. Because Oxlo.ai charges a flat rate per request, you can paste multi-page protocols or lengthy observation logs without watching token meters spin.

Two concrete ways to extend this. First, pipe the structured JSON into an Electronic Lab Notebook API like Benchling to auto-populate entry fields. Second, index the extracted genes and reagents with Oxlo.ai's BGE-Large embeddings endpoint to surface related experiments from your lab's history.

Top comments (0)