Tutorial Examples Structured Extraction LLM Multimodal ~3 min read

Structured Extraction from Patient Intake Form with LLM

Extract typed Patient records from PDF and DOCX intake forms with an LLM and CocoIndex v1: the nested schema is the whole prompt; results land in Postgres.


Updated Jul 9, 2026

Patient intake forms arrive as PDFs and Word documents, in whatever layout each clinic uses. This post builds a CocoIndex v1 pipeline that reads those forms from Google Drive, extracts a fully typed Patient record with an LLM, and writes it to Postgres. The interesting part is that a single Python schema does all the instructing: there is no prompt template describing the fields, because the dataclass is the contract the model fills in.

This is the plain-LLM version. If you want the same extraction with a dedicated framework, see the BAML and DSPy variants. The full code is in the CocoIndex v1 repo.

The flow

Each form is its own processing component: convert the document to Markdown, extract the Patient with the LLM, and declare a row in Postgres:

Flow: Google Drive holds intake forms as PDF and DOCX; one processing component per file converts the document to Markdown, extracts a typed Patient record with an LLM, and declares a row into the Postgres patients_info table.

The schema is the prompt

The schema follows the FHIR Patient resource, simplified to a top-level Patient plus eight nested types. It is plain Python: nested objects (address, emergency_contact, pharmacy), optional fields marked | None, and lists for repeated items (past_conditions, current_medications, allergies, surgeries).

The Patient schema is the extraction contract: a top-level Patient with scalar fields, nested objects (Address, Contact, Insurance, Pharmacy), optional fields marked None, and lists of items (Condition, Medication, Allergy, Surgery). Address is reused by both Patient and Pharmacy rather than re-described.

The whole schema is a set of Pydantic models. instructor uses these as the response schema, so the model returns data that matches the Python types instead of free-form text:

python
import datetime
from pydantic import BaseModel, Field


class Address(BaseModel):
    street: str
    city: str
    state: str
    zip_code: str


class Contact(BaseModel):
    name: str
    phone: str
    relationship: str


class Insurance(BaseModel):
    provider: str
    policy_number: str
    group_number: str | None = None
    policyholder_name: str
    relationship_to_patient: str


class Pharmacy(BaseModel):
    name: str
    phone: str
    address: Address


class Condition(BaseModel):
    name: str
    diagnosed: bool


class Medication(BaseModel):
    name: str
    dosage: str


class Allergy(BaseModel):
    name: str


class Surgery(BaseModel):
    name: str
    date: str


class Patient(BaseModel):
    name: str
    dob: datetime.date
    gender: str
    address: Address
    phone: str
    email: str
    preferred_contact_method: str
    emergency_contact: Contact
    insurance: Insurance | None = None
    reason_for_visit: str
    symptoms_duration: str
    past_conditions: list[Condition] = Field(default_factory=list)
    current_medications: list[Medication] = Field(default_factory=list)
    allergies: list[Allergy] = Field(default_factory=list)
    surgeries: list[Surgery] = Field(default_factory=list)
    occupation: str | None = None
    pharmacy: Pharmacy | None = None
    consent_given: bool
    consent_date: str | None = None

One declaration covers the whole intake form. The nested Address inside both Patient and Pharmacy is reused rather than re-described, and the | None markers tell the model which fields are allowed to be absent.

Convert any format to Markdown

An LLM cannot read a raw PDF or DOCX well, so each file is first converted to Markdown with MarkItDown, which handles PDF, Word, Excel, images, and more:

python
import os
import tempfile
from markitdown import MarkItDown

_md = MarkItDown()


def to_markdown(content: bytes, filename: str) -> str:
    suffix = os.path.splitext(filename)[1] or ".pdf"
    with tempfile.NamedTemporaryFile(delete=True, suffix=suffix) as tmp:
        tmp.write(content)
        tmp.flush()
        return _md.convert(tmp.name).text_content or ""

Extract the Patient

Extraction is a single CocoIndex function. It calls the model through litellm and instructor, which forces the response to match the Patient schema. memo=True caches the result, so an unchanged form is never sent to the model twice. Swapping gpt-4o for a local Ollama model or another provider is a one-line change of LLM_MODEL:

python
import instructor
import litellm
import cocoindex as coco

from models import Patient

LLM_MODEL = coco.ContextKey[str]("llm_model", detect_change=True)


@coco.fn(memo=True)
async def extract_patient(markdown: str) -> Patient:
    client = instructor.from_litellm(litellm.acompletion, mode=instructor.Mode.JSON)
    result = await client.chat.completions.create(
        model=coco.use_context(LLM_MODEL),
        response_model=Patient,
        messages=[
            {"role": "system", "content": "Extract patient information from the intake form."},
            {"role": "user", "content": markdown},
        ],
    )
    return Patient.model_validate(result.model_dump())

Declare a row per form

The Postgres row keeps a few flat columns for easy querying and stores the full nested record in a jsonb column (CocoIndex maps the dict to jsonb automatically). process_form reads one Drive file, converts it, extracts the patient, and declares the row:

python
from dataclasses import dataclass
import datetime

from cocoindex.connectors import google_drive, postgres


@dataclass
class PatientRecord:
    filename: str  # primary key
    name: str
    dob: datetime.date
    reason_for_visit: str
    patient: dict[str, object]  # the full Patient, as jsonb


@coco.fn(memo=True)
async def process_form(
    file: google_drive.DriveFile,
    table: postgres.TableTarget[PatientRecord],
) -> None:
    markdown = to_markdown(await file.read(), file.file_path.path.name)
    patient = await extract_patient(markdown)
    table.declare_row(
        row=PatientRecord(
            filename=file.file_path.path.name,
            name=patient.name,
            dob=patient.dob,
            reason_for_visit=patient.reason_for_visit,
            patient=patient.model_dump(mode="json"),
        )
    )

Wire up the app

Connections are provided once in the lifespan; this is also where CocoIndex’s local processing-state file is set. app_main mounts the Postgres table, points the Google Drive source at your folders, and fans out one process_form per file with mount_each:

python
import pathlib
from collections.abc import AsyncIterator

import asyncpg

DATABASE_URL = os.environ["POSTGRES_URL"]
PG_DB = coco.ContextKey[asyncpg.Pool]("patient_db")


@coco.lifespan
async def coco_lifespan(builder: coco.EnvironmentBuilder) -> AsyncIterator[None]:
    builder.settings.db_path = pathlib.Path("./cocoindex.db")
    builder.provide(LLM_MODEL, os.environ.get("LLM_MODEL", "openai/gpt-4o"))
    async with asyncpg.create_pool(DATABASE_URL) as pool:
        builder.provide(PG_DB, pool)
        yield


@coco.fn
async def app_main() -> None:
    table = await postgres.mount_table_target(
        PG_DB,
        table_name="patients_info",
        table_schema=await postgres.TableSchema.from_class(
            PatientRecord, primary_key=["filename"]
        ),
        pg_schema_name="coco_examples",
    )

    source = google_drive.GoogleDriveSource(
        service_account_credential_path=os.environ["GOOGLE_SERVICE_ACCOUNT_CREDENTIAL"],
        root_folder_ids=[
            f.strip()
            for f in os.environ["GOOGLE_DRIVE_ROOT_FOLDER_IDS"].split(",")
            if f.strip()
        ],
    )
    await coco.mount_each(process_form, source.items(), table)


app = coco.App(coco.AppConfig(name="PatientIntakeExtraction"), app_main)

To load from a local folder instead of Google Drive, swap the source for localfs.walk_dir; the rest of the flow is unchanged.

Run and query

Set the environment (a Postgres URL, an OpenAI key, and Google Drive service-account credentials; see Setup for Google Drive), then build the index:

sh
pip install "cocoindex[postgres,google_drive]" instructor litellm markitdown asyncpg
cocoindex update main

Re-running processes only new or changed forms, since per-form extraction is memoized. Query the result in Postgres, where the flat columns and the jsonb record are both available:

sql
SELECT filename, name, reason_for_visit,
       patient -> 'allergies' AS allergies
FROM coco_examples.patients_info;

Extraction quality

LLM extraction is not perfect, and evaluation is the real work. Structured forms with clear labels are the easy case; a scan with an unusual layout can drop a field or misread a value. When accuracy matters, keep a set of hand-checked golden records and diff each run against them, and try a different model or converter on the forms that miss. Because the schema is a single declaration, tightening a field description or swapping the model is a small change with the same typed output.

Support us

We are constantly adding examples and improving the runtime. If this was helpful, please star CocoIndex on GitHub.

CocoIndex

Fresh context for long-horizon agents.

Frequently asked questions.

How do I extract structured data from patient intake forms with an LLM in CocoIndex v1?

Convert each form to Markdown, then run one CocoIndex function that calls the model through litellm and instructor with a Pydantic Patient schema as the response_model, so the model returns data matching the Python types. Wrap it in @coco.fn(memo=True) so an unchanged form is never re-extracted, then declare_row the result into Postgres.

See Extract the Patient.

How do I handle PDF and DOCX forms in different formats?

Convert every document to Markdown first, so the LLM step works the same regardless of source format. This example uses MarkItDown, which handles PDF, Word, Excel, images, and more, in a small to_markdown helper that writes the bytes to a temp file and returns the text.

See Convert any format to Markdown.

How do I define the schema for patient extraction, and why is it the prompt?

Define the target shape as Pydantic models: a top-level Patient plus nested types (Address, Contact, Insurance, Pharmacy) and lists (Condition, Medication, Allergy, Surgery), modeled on the FHIR Patient resource. That one declaration is the whole instruction to the model — there is no separate prompt template — and | None markers tell it which fields may be absent. Nested Address is reused by both Patient and Pharmacy.

See The schema is the prompt.

Can I load intake forms from Google Drive, or from local files?

Both. Point google_drive.GoogleDriveSource at your service-account credentials and root folder IDs and hand source.items() to coco.mount_each, one component per form. To read local files instead, swap the source for localfs.walk_dir and the rest of the flow is unchanged.

See Wire up the app.

Where is the extracted data stored, and how do I query it?

Each form becomes one row in the Postgres patients_info table, keyed by filename. The row keeps a few flat columns (name, dob, reason for visit) for easy querying plus the full nested record in a jsonb column, which CocoIndex maps from the Python dict automatically. Query it with plain SQL, including JSON operators like patient -> 'allergies'.

See Run and query.

How do I evaluate and improve extraction quality?

LLM extraction is not perfect, so keep a set of hand-checked golden records and diff each run against them to spot missing or misread fields. On the forms that miss, try a different model or Markdown converter. Because the schema is a single declaration, tightening a field description or swapping LLM_MODEL is a small change with the same typed output.

See Extraction quality.