Skip to content

Extracting Structured Fields From a Property Management Inspection Checklist

9 min read · updated August 11, 2026

The reason this form is filled in at all is that it will be compared with a second copy of itself months later. Every extraction decision follows from that, and a flat list of findings makes the comparison impossible.

A table of tables

The document is a sequence of room blocks, each containing the same small set of items — walls, ceiling, floor, windows, doors, fixtures, and in kitchens and bathrooms a longer list — and each item carrying a condition mark and a comment. It looks like one long table and it is not. It is a nested structure that has been flattened onto paper, and flattening it again into a document-level list of findings loses the containment: “stain, 30cm, near skirting” is a different fact depending on which room it is in, and after flattening you cannot get the room back.

So the target shape is an array of rooms, each with an array of items — the nested rather than the flat schema, for once without argument. The number of rooms is not fixed, the item list varies by room type, and both of those are properties of the property rather than of the form. That variability is the reason a rigid schema with named fields per room fails on the second building you try it on, and it is the ordinary problem of designing a schema for variants you have not seen.

{
  "form_type": "move_in",
  "unit": "Unit 4B, 118 Sample Ave",
  "inspected_on": "2026-06-01",
  "rooms": [
    {
      "room_key": "bedroom_2",
      "room_label_as_printed": "Bedroom 2",
      "items": [
        { "item_key": "walls",  "condition": "good",
          "comment": null, "photo_refs": [] },
        { "item_key": "carpet", "condition": "fair",
          "comment": "worn patch by wardrobe", "photo_refs": ["IMG_0442"] },
        { "item_key": "window", "condition": null,
          "comment": null, "mark_absent_reason": "not_marked" }
      ]
    }
  ],
  "signatures": { "tenant": true, "agent": true }
}

Room identity has to be stable

A comparison between two forms is a join, and a join needs a key. Room labels are terrible keys. “Bedroom 2” on the move-in form may be “Second Bedroom” or “Bed 2” on the move-out form, filled in by a different agent. Worse, room order can differ, so a positional join — third block against third block — silently compares a bathroom with a hallway.

Assign a normalised room_key and keep the printed label beside it, exactly as with any other controlled-vocabulary mapping in this cluster. Where the property has a floor plan or a unit schedule, key against that instead of against the form’s own text; where it does not, normalise conservatively and leave anything that does not map confidently as an unmatched room rather than forcing it. An unmatched room shown to a human is a five-second fix. A wrongly matched one produces a deposit dispute.

The same argument applies one level down to item_key. Item lists differ between form vendors, and the item that is “Floor covering” on one form is “Carpet/Vinyl” on another. Normalise, keep the printed text, and never assume two forms enumerate items in the same order.

The mark means nothing without its column

This is the document-specific failure, and it is a layout problem rather than a prompting one. The condition is recorded by a mark in one of several columns — Good, Fair, Poor, N/A, or a numeric scale — and the mark itself carries no information at all. Its meaning is entirely positional: it comes from the column header. Which means the extraction depends on getting a horizontal coordinate right, and three things routinely break that.

  • The header does not repeat. Column headers are printed once, usually at the top of page one. On page three there are four unlabelled columns of ticks. If pages are processed independently — which is the default in most page-at-a-time pipelines — the model has to guess, and it will guess plausibly and wrongly. Carry the header definition forward across pages explicitly, or process the room blocks with the header in context; this is the general problem of column misalignment across pages, arriving on a form where the columns are checkboxes.
  • The scan is skewed. A photographed or phone-scanned form has a few degrees of rotation, and a few degrees is enough to move a mark near the bottom of the page into the neighbouring column. Deskewing before extraction is worth more here than a better prompt; the same class of geometric problem is covered in PDF parsing and the OCR pipeline.
  • The mark is between columns. People tick on lines. When the mark straddles a boundary the honest output is an ambiguous condition routed to review, not the nearer column. Good becomes Fair by a millimetre, and Fair is what a deduction is argued from.

Blank is not the same as fine

An unmarked item is the most common state on a real form and its meaning is defined by the form’s instructions, not by convention. Some forms instruct the inspector to mark only exceptions, so blank means satisfactory. Others require a mark for every line, so blank means the item was not inspected — a genuinely different claim, and the one that matters when somebody later asks whether the damage was there at move-in.

Your extraction cannot resolve that from the marks. It can and should record the distinction: condition: null with a mark_absent_reason of not_marked, and a form-level setting recording which convention this form template uses. Then the interpretation lives in one place, per template, where it can be checked, instead of being baked into thousands of extracted records as an assumption nobody wrote down.

Two related marks deserve their own values rather than being folded into a condition scale. N/A means the item does not exist in this room — there is no dishwasher — and it is not a condition. A struck-through row means the same thing more emphatically. Neither is “poor”, and neither should count in any aggregate over conditions.

Move-in against move-out

The output the business actually wants is not the extraction. It is the difference between two extractions of the same template for the same unit, months apart, because the change in condition is what any deposit conversation is about. Design for that from the start and several decisions above stop being matters of taste.

  • Stable room and item keys exist so the join is possible at all.
  • Conditions map to an ordered scale so “good to fair” is a direction rather than a pair of strings.
  • The comment travels with the item, because “fair — pre-existing, noted at move-in” and a bare “fair” are not the same finding.
  • Photo references are per item, so a diff can present the two images of the same carpet side by side rather than two galleries.
  • An item present on one form and absent on the other is reported as unmatched, never as a change. Forms get revised between tenancies.

Everything about this page has been about structure rather than model selection, and that is the point. The condition marks themselves are the easy part. What makes this document hard is that its meaning lives in a header row that appears once and in a comparison that happens six months later, and neither of those survives a schema designed by looking at page one.