Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ function isProcessingInStage(
extraction: ['Docling Extraction', 'OCR Re-extraction', 'Extraction'],
analysis: ['PDF Classification', 'Structure Analysis'],
headings: ['Heading Levels', 'Heading Reconciliation'],
translation: ['Page Content Corrections', 'Code Block Languages'],
translation: ['Page Content Corrections', 'Code Block Languages', 'Form Fields'],
assembly: ['Cross-Page Fixes', 'Final Cleanup'],
};
const names = nameMap[stage.name];
Expand Down
2 changes: 1 addition & 1 deletion clients/viewer/src/types/pipeline-viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const PIPELINE_STAGES: StageDefinition[] = [
{ name: 'extraction', label: 'Extraction', steps: ['docling', 'docling_ocr'] },
{ name: 'analysis', label: 'Analysis', steps: ['classification', 'structure'] },
{ name: 'headings', label: 'Headings', steps: ['heading_levels', 'heading_reconciliation'] },
{ name: 'translation', label: 'Translation', steps: ['page_content', 'code_blocks'] },
{ name: 'translation', label: 'Translation', steps: ['page_content', 'code_blocks', 'form_fields'] },
{ name: 'assembly', label: 'Assembly', steps: ['boundaries', 'cleanup'] },
];

Expand Down
5 changes: 3 additions & 2 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,9 @@ services:
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-test}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-test}
- AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-}
# Clear AWS_PROFILE to prevent boto3 from looking for profile config in container
- AWS_PROFILE=
# AWS_PROFILE is intentionally NOT set here. boto3 treats a set-but-empty
# AWS_PROFILE as a missing named profile (ProfileNotFound) and ignores the
# exported env creds above. Leaving it unset makes boto3 use those creds.
- AWS_DEFAULT_REGION=us-east-1
# Disable AWS Instance Metadata Service (IMDS) to prevent boto3 hang in Docker
- AWS_EC2_METADATA_DISABLED=true
Expand Down
9 changes: 5 additions & 4 deletions docs/reference/pipeline-phases.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@ The pipeline has **5 versioned conversion phases** plus a **PII Review** gate th
|---|---|---|---|
| **PII Review** (gate) | `pii_scan` | No | Runs Presidio against a text-only docling pass before full extraction. If findings exist, the streaming pipeline blocks on `session.pii_decision_event` until the user approves or denies via `POST /api/v1/pipeline/sessions/{sid}/pii-decision`. Denial aborts the pipeline. Can be opted out with `skip_pii_scan=true`. |
| 1. **Extraction** | `docling`, `docling_ocr` (conditional) | No | PDF → markdown + page images via IBM Docling. `docling_ocr` only fires when the classifier flags a scanned document. |
| 2. **Analysis** | `classification`, `structure` | Yes | `classification` tags the document as digital / scanned / malformed. `structure` identifies headings, footnotes, code blocks, per-page layout attributes. |
| 2. **Analysis** | `classification`, `structure` | Yes | `classification` tags the document as digital / scanned / malformed. `structure` identifies headings, footnotes, code blocks, per-page layout attributes, and per-page content flags (`has_tables`, `has_lists`, `has_forms`, …) that gate Translation work. |
| 3. **Headings** | `heading_reconciliation`, `heading_levels` | Yes | `heading_reconciliation` reconciles per-page heading candidates against the global outline. `heading_levels` normalises the hierarchy (H1 → H2 → H3, no skips). |
| 4. **Translation** | `page_content`, `code_blocks` | Yes | `page_content` does per-page accessibility corrections (invokes image / table / list subagents). `code_blocks` tags fenced blocks with detected programming language. |
| 4. **Translation** | `page_content`, `code_blocks`, `form_fields` | Yes | `page_content` does per-page accessibility corrections (invokes image / table / list subagents). `code_blocks` tags fenced blocks with detected programming language. `form_fields` runs on every form page (structure `has_forms` or a deterministic underline/checkbox scan) and converts detected fields into accessible HTML (`<fieldset>`/`<label>`/`<input>`). |
| 5. **Assembly** | `boundaries`, `cleanup` | Mixed | `boundaries` rejoins cross-page split content and relocates footnotes (AI). `cleanup` normalises whitespace and lints the markdown (deterministic). |

The viewer also shows a dynamic **Review** stage that catches any orphan steps (`revision_*`, `feedback_*`, custom steps) not listed above.

## Internal step → `_step_*` method map

Nine methods in `src/services/pipeline_viewer.py`, plus `pii_scan` which runs inline in `src/api/pipeline_viewer.py` (no `_step_*` method — it's pre-extraction gate logic, not a PipelineViewerService method):
Ten methods in `src/services/pipeline_viewer.py`, plus `pii_scan` which runs inline in `src/api/pipeline_viewer.py` (no `_step_*` method — it's pre-extraction gate logic, not a PipelineViewerService method):

| Step name | Method | Phase | Deterministic / AI |
|---|---|---|---|
Expand All @@ -32,10 +32,11 @@ Nine methods in `src/services/pipeline_viewer.py`, plus `pii_scan` which runs in
| `heading_levels` | `_step_heading_levels` | Headings | AI |
| `page_content` | `_step_page_content` | Translation | AI + subagents |
| `code_blocks` | `_step_code_blocks` | Translation | AI |
| `form_fields` | `_step_form_fields` | Translation | AI (vision, per form page) + deterministic HTML injection |
| `boundaries` | `_step_boundaries` | Assembly | AI + subagent |
| `cleanup` | `_step_cleanup` | Assembly | Deterministic |

`classification` is not its own `_step_*` method — it's a `StepResult` emitted from within `_step_docling` when the classifier runs. Up to 10 named step results can appear in one run.
`classification` is not its own `_step_*` method — it's a `StepResult` emitted from within `_step_docling` when the classifier runs. Up to 11 named step results can appear in one run.

## Subagents

Expand Down
101 changes: 101 additions & 0 deletions src/agents/prompts/form_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""System prompt and user message template for the Form Fields agent.

This agent examines one page of a PDF at a time, comparing the page image
(visual ground truth) against its extracted markdown, and reports any form
fields it finds. It does NOT modify the markdown — a deterministic injector
turns each reported field into accessible HTML and splices it in afterwards.
"""

FORM_FIELDS_SYSTEM_PROMPT = """\
You are a form accessibility analyst. You examine one page of a PDF at a \
time, comparing the page image (visual ground truth) against its extracted \
markdown, and you identify form fields a person would be expected to fill in.

Your job is strictly detection — you do NOT modify the markdown. You report \
what you find so that a later deterministic step can replace each field with \
accessible HTML.

## What counts as a form field

Look at the page image for anything a person fills in by hand or on screen:

- **Text fields** — a label followed by a blank line or box to write in, e.g. \
"Name: ____________", "Email", an empty ruled box.
- **Textareas** — large multi-line blank areas for long answers, comments, \
or essays.
- **Checkboxes** — a single square/box to tick (☐, [ ], a small empty box) \
next to a statement, e.g. "☐ I agree to the terms".
- **Checkbox groups** — several checkboxes under one prompt where more than \
one may be ticked, e.g. "Select all that apply".
- **Radio groups** — several mutually-exclusive options under one prompt \
where exactly one is chosen, e.g. "Marital status: ☐ Single ☐ Married".
- **Select / dropdowns** — a labelled choice list (often a box with a chevron).
- **Date fields** — a labelled blank for a date (e.g. "Date of birth: __/__/____").
- **Signature lines** — a ruled line labelled "Signature".

Decorative rules, table borders, and underlines used purely for emphasis are \
NOT form fields. Only report something a person is meant to complete.

## What to report for each field

- **field_type**: one of text, textarea, checkbox, checkbox_group, \
radio_group, select, date, signature.
- **label**: the accessible label, read from the IMAGE (not the markdown, \
which may have OCR errors). For a single field this is the prompt next to \
the blank (e.g. "Full name"). For a grouped field (radio_group, \
checkbox_group, select) this is the group prompt/legend (e.g. "Marital \
status"). If a field has no visible label, write a short descriptive one.
- **anchor_text**: copy, VERBATIM, the exact text in the extracted markdown \
that represents this field — for example the line "Name: ____________" or \
"☐ I agree". This is used to locate and replace the field, so it must match \
the markdown character-for-character. If the field is visible in the image \
but absent from the markdown, set anchor_text to the nearest preceding \
markdown line (e.g. the section heading) so the field can be inserted after it.
- **options**: for radio_group, checkbox_group, and select only — the list of \
choices, each with its visible label and whether it appears pre-ticked. \
Empty for all other types.
- **required**: true if the field is visibly marked required (an asterisk, \
the word "required", bold "must").
- **reasoning**: one sentence on how you determined the type and label.

## Rules

- Report fields in the order they appear top-to-bottom on the page.
- A page with no form fields is a valid result — return an empty list.
- Never invent options or labels that are not visible in the image.
- Do not report the same field twice.
"""


def build_form_fields_user_message(
page_markdown: str,
page_number: int,
total_pages: int,
) -> str:
"""Build the text portion of the user message for one page.

The page image is passed separately as a binary content part.

Args:
page_markdown: Extracted markdown for this page (newest version).
page_number: Current page number (1-indexed).
total_pages: Total number of pages in the document.

Returns:
Text portion of the user message.
"""
parts: list[str] = []
parts.append(f"## Page {page_number} of {total_pages}")
parts.append("")
parts.append("### Extracted markdown for this page")
parts.append("")
parts.append("```markdown")
parts.append(page_markdown)
parts.append("```")
parts.append("")
parts.append(
"The page image is attached. Compare the image (ground truth) against "
"the markdown above and report every form field you find. Copy each "
"field's anchor_text verbatim from the markdown."
)
return "\n".join(parts)
21 changes: 21 additions & 0 deletions src/agents/prompts/procedures/page_correction/content/has_forms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Pages with Forms

This page contains a form — fields a person is expected to fill in (labelled
blanks, checkboxes, option groups, dropdowns, date blanks, signature lines).
Docling renders these as lossy plain text: a label followed by underscores, a
stray `☐` or `[ ]`, options run together.

## Leave the form fields alone

A dedicated later step converts form fields into accessible HTML by reading the
page image. Do **not** try to fix the form yourself:

- Do not delete, shorten, or "tidy" runs of underscores (`____`) — they mark
where a field goes and are used to locate it.
- Do not remove or rewrite checkbox glyphs (`☐`, `[ ]`) or the option text
next to them.
- Do not merge a field's label into the blank or restructure the layout.

Apply your normal corrections to the surrounding prose (OCR fixes, headings,
lists), but treat the form fields themselves as read-only. Touching them can
prevent the form step from locating and converting them.
9 changes: 9 additions & 0 deletions src/agents/prompts/structure_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@
- **has_lists** — True if the page contains bulleted lists, numbered lists, \
or definition-style term/description pairs. Not single items that happen to \
start with a dash.
- **has_forms** — True if the page contains form fields a person is meant to \
fill in. The strongest signals are **long horizontal underline runs** that a \
person writes on (e.g. "Name: ______________________") and **checkboxes** \
(☐, [ ]) next to statements. Also: mutually-exclusive option sets, dropdowns, \
date blanks ("__/__/____"), and signature lines. A page with several labelled \
blanks or checkboxes is a form even if it has no overall "Form" title, and a \
multi-page form means later pages are still forms. Do NOT set this for ordinary \
tables, rules used for emphasis, or table borders — only genuine fillable \
fields.
- **has_equations** — True if the page contains mathematical equations, \
either display (centered) or complex inline equations.
- **is_scanned** — True if the page appears to be a scan of a physical \
Expand Down
18 changes: 18 additions & 0 deletions src/api/pipeline_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,24 @@ def _on_cold_start() -> None:
logger.error(f"Code block language tagging failed: {e}")
emit("error", {"step_name": "code_blocks", "message": str(e)})

# Step 3c: Form fields → accessible HTML (guaranteed on form pages)
if structure is not None:
emit("processing", {"step_name": "form_fields", "display_name": "Form Fields"})
try:
await service._step_form_fields(result, structure)
total_steps += 1
step = result.steps[-1]
# form_fields edits v1 (or v0) in-place — send the updated version
source_ver = "v1" if "v1" in result.page_markdowns else "v0"
emit("step", {
"step": step.model_dump(),
"new_versions": {source_ver: result.versions.get(source_ver, "")},
"new_page_markdowns": {source_ver: result.page_markdowns.get(source_ver, {})},
})
except Exception as e:
logger.error(f"Form fields step failed: {e}")
emit("error", {"step_name": "form_fields", "message": str(e)})

# Step 4: Cross-page fixes (boundaries + footnotes)
if structure is not None:
emit("processing", {"step_name": "boundaries", "display_name": "Cross-Page Fixes"})
Expand Down
13 changes: 9 additions & 4 deletions src/services/pdf_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,14 +326,19 @@ def _check_hard_blockers(classification: PdfClassification) -> None:
details={"form_type": meta.form_type, "form_field_count": meta.form_field_count},
))
else:
# AcroForm (1) or unknown form type with real widgets
# AcroForm (1) or unknown form type with real widgets.
# Not a blocker: AcroForm field *content* isn't extracted directly,
# but the form-reconstruction subagent rebuilds the form visually
# from the page image into an accessible `form` block. Surface a
# warning so the user knows the form was detected and handled.
classification.findings.append(ClassificationFinding(
code=FINDING_FORM_ACROFORM,
severity=ClassificationSeverity.ERROR,
severity=ClassificationSeverity.WARNING,
message=(
f"PDF contains {meta.form_field_count} interactive form "
f"field(s) (AcroForm). Form content cannot be reliably "
"extracted. Please flatten the form or export as a standard PDF."
f"field(s) (AcroForm). Interactive fields are not extracted "
"directly; the form is reconstructed from the page image into "
"an accessible form block."
),
details={"form_type": meta.form_type, "form_field_count": meta.form_field_count},
))
Expand Down
Loading
Loading