+ Planfacts

The benefit document parser at the heart of quoting

AI-powered PDF → structured-data parser. Two services in concert — Planfacts API (Python wrapper) and OpenPlan (Python schema definitions). Field-by-field audit across all 10 benefit types. v2 reads from both sides — Plansight (Laravel) and the API (Python) — so most "verify with Plan Facts" items from v1 are now confirmed or refuted with ground-truth evidence.

Direction: Inbound (PDF parse) Services: Planfacts API + OpenPlan Audit version: v2 · 2026-05-02 Plansight APP: 9d1afd54d5df Planfacts API: 0b2d9a11ef3f OpenPlan: 270e51fe72de Docs: openplan-test.plansight.com/docs ↗
Bottom line

The architecture is sound. Findings are surface-level fixes.

The parser dispatch pattern, the staging step through QuoteAi->aiResponseParsed, the per-benefit parser separation, and the multi-network detection in medical/dental are all well-designed. The four likely-bug findings are concrete one-line or few-line fixes — none require structural change.

What's confirmed by API ground truth in v2

Question from v1v2 ruling
Hospital Indemnity — does the API have a separate Substance Abuse field?Yes — Plansight bug confirmed The API returns Other Conditions.[Class].Substance Abuse as a dedicated key. Plansight reads from Mental/Nervous for both fields.
Vision — does the API consistently use "In-Network" / "Out-of-Network"?Yes — assumption confirmed The API hardcodes those exact two network names.
Vision — are out-of-network "Discount Beyond Allowance" fields returned?No — Plansight is correct The API genuinely doesn't return these for OON; Plansight not mapping them is intentional, not a gap.
Critical Illness — is "Angioplasty" separate or nested under Coronary Arteriosclerosis?Separate top-level key The cross-mapping to criticalIllnessCoronaryArteriosclerosis is a Quote-side naming choice, not an API-level confusion.
Critical Illness — Childhood conditions structure?Presence flag with "Initial" amount Plansight's binary read is approximately correct; tightening for explicit "No" / "Not Included" values is still useful.
Voluntary Life — does the API return separate Smoker / Non-Smoker rates?No — uni-smoker only The API uses Unismoker (singular). Plansight's approach matches.
Group Life — Contribution Type enum values?Confirmed Noncontributory, Contributory, Voluntary, Gross-up, Tax choice. Plansight's enum reversal at line 1052 is still a bug.
Disability — does the API natively return per-class data?Yes for everything The API returns per-class data on every benefit field. Plansight's intentional collapse to plan-level on most fields (per the domain model) is a deliberate downstream decision.

What's still not determinable from source alone

Shipping within a week (not yet in audited code)

This audit is a snapshot of Plansight APP 9d1afd54d5df, Planfacts API 0b2d9a11ef3f, and OpenPlan 270e51fe72de. Two enhancements are imminent and worth flagging now even though they aren't yet in the GitHub repo:

Both features will appear in the next Bitbucket pull. When they land, this audit should refresh the cover blurb, the "How to read" section, and add per-benefit notes about which fields the hover-trace surfaces.

Per-benefit detail pages

Status at a glance

Click any benefit to open its detail page — same audit shape on each: per-class handling, what's mapped, fields the API returns we don't read, potentially-mapped-wrong items with Cursor prompts inline.

Medical

6 items

POST /medical · parseQuoteMedical()

Multi-network parse (1–3 networks). Items: enum casing, network sort, drug tier cap, $0 deductibles.

Dental

4 items

POST /dental · parseQuoteDental()

Multi-network with annual max, rollover, waiting periods. Items: brittle regex, dual-stored rollover.

Vision

4 items

POST /vision · parseQuoteVision()

Fixed 2-network parse — confirmed correct. Items: dual-key Lenses, Yes/No matchers, Disposable Contacts.

Short-Term Disability

Verify

POST /std · parseQuoteStDisability()

One plan, three per-class variables (benefit %, max, tax). Domain model implemented correctly.

Long-Term Disability

Verify

POST /ltd · parseQuoteLtDisability()

Like ST plus 3 LT extras to confirm. Line 3676 cross-write to ST field.

Accident

Verify

POST /accident · parseQuoteAccident()

Single-class. Surgery-Benefit field naming question; Emergency Room missing Type field.

Critical Illness

Verify

POST /criticalIllness · parseQuoteCriticalIllness()

Single-class with ~20 conditions. Angioplasty mapping; binary Childhood Conditions; Skin Cancer Type fragility.

Hospital Indemnity

Bug confirmed

POST /hospitalIndemnity · parseQuoteHospitalIndemnity()

Confirmed bug: Substance Abuse reads from Mental/Nervous source. API has dedicated key.

Group Life

Likely bugs

POST /life_add · parseQuoteGroupLife()

Writes to disabilityWaiverWaitingPeriod (Disability field) inside Life parser. Contribution Type semantic reversal.

Voluntary Life

Bug confirmed

POST /life_add · parseQuoteVoluntaryLife()

Confirmed bug: line 805 missing dot in path string — Spouse Benefit Amount silently fails.

Cross-cutting findings

Patterns across more than one parser

Four shapes that show up in multiple parsers. Worth knowing so the same fix doesn't have to be discovered three times.

Field-name cross-writes appear in three parsers

Group Life writes to disabilityWaiverWaitingPeriod (a Disability field name) at line 1140. Hospital Indemnity reads from the Mental/Nervous source path for both Mental/Nervous AND Substance Abuse (line 1847). LT Disability at line 3676 writes the LT Definition of Disability into stDisabilityDefinitionOfDisability (the ST field). All three have the same root cause: copy-paste between similar parsers without renaming the destination. Worth a one-time grep sweep across PlanfactService.php for any other places where one parser assigns to another's field prefix.

Path-string concatenation is fragile

Several parsers concatenate Plan Facts response paths as strings — e.g. 'X.' . $class . '.Y'. Voluntary Life line 805 has a missing-dot bug that breaks the Spouse Benefit Amount lookup silently. Worth a sweep for similar mistakes elsewhere — anywhere a variable is followed by another quoted string with no leading dot is suspect.

Enum mapping is mostly case-sensitive

Some fields use strtolower() before matching, others don't. The Medical Plan Type switch at line 3991 is case-sensitive while neighboring enums in the same parser are not. A Plan Facts response with unusual casing ("hmo" vs "HMO") could silently fail to match without warning.

The Substance Abuse pattern matters beyond Hospital Indemnity

The bug pattern of "two consecutive blocks both reading the same source path, writing to two different Quote fields" is worth checking elsewhere. The fix for Hospital Indemnity is a one-line source-path change. A grep for similar shapes would surface any other instances.

How to read

Each per-benefit page follows the same shape

Eight sections per benefit page. The most useful sections for a developer are "Potentially mapped incorrectly" (each item has a Cursor prompt) and "Fields Plan Facts returns that Plansight doesn't read."

SectionWhat you'll find
Bottom lineOne sentence: what to walk away with for this benefit.
Per-class / per-tier handlingHow the parser handles classes / tiers / networks, and how that maps to Plansight's domain model.
What's mappedEvery Plan Facts field that flows into a Quote field, with file:line. Confirmed against the API source where possible.
Plan-level (by design)Fields the API returns per-class where Plansight intentionally collapses to plan-level.
Fields Plan Facts returns that Plansight doesn't readNew in v2 — derived from cross-referencing the API schema. Candidates for ingestion or explicit "we don't need this" decisions.
Potentially mapped incorrectlyCross-writes, enum mismatches, regex gaps, dropped fields the form expects. Each item includes a copy-pasteable Cursor prompt.
Manual-entry-only fieldsQuote form fields the broker has to fill in because the API doesn't return them.
Recommendations2–4 small concrete items per benefit. No framework refactors, no unified parsers.
Code references

Where it lives across both codebases

Quick file:line lookup. Line numbers may drift between Bitbucket pulls — verify against current code with grep before applying any prompt.

Plansight APP — Laravel parser

FileLinesPurpose
app/Services/PlanfactService.php132–304API endpoint configs
app/Services/PlanfactService.php446–465parseQuote() dispatch switch
app/Services/PlanfactService.php469–970parseQuoteVoluntaryLife() + line 805 path bug
app/Services/PlanfactService.php972–1362parseQuoteGroupLife() + Contribution Type / disabilityWaiver concerns
app/Services/PlanfactService.php1365–1701parseQuoteAccident()
app/Services/PlanfactService.php1703–1921parseQuoteHospitalIndemnity() + Substance Abuse cross-write at 1847
app/Services/PlanfactService.php1923–2310parseQuoteCriticalIllness() + Angioplasty mapping at 2108
app/Services/PlanfactService.php2313–2443parseQuoteVision()
app/Services/PlanfactService.php2504–3059parseQuoteDental()
app/Services/PlanfactService.php3061–3447parseQuoteStDisability()
app/Services/PlanfactService.php3449–3951parseQuoteLtDisability() + line 3676 cross-write
app/Services/PlanfactService.php3984–4507parseQuoteMedical()
app/Services/PlanfactService.php4509–4687parseMoney() and parseCopay() helpers
app/Http/Controllers/PlanfactsController.php165Stage parsed result on QuoteAi->aiResponseParsed
app/Http/Controllers/Rest/App/QuoteController.php501$model->fill(...) to Quote

Planfacts API — Python wrapper

FileLinePurpose
main.py6909POST /medical
main.py7351POST /life_add
main.py7592POST /std
main.py8055POST /ltd
main.py8973POST /vision
main.py10398POST /ci
main.py11153POST /hi

OpenPlan — Python schema definitions

FileLinesPurpose
sources_simple.py702–708Medical Networks (5 keys: IN1/IN2/IN3/OON/OOA)
sources_simple.py900Medical Drug Tiers (no explicit cap)
sources_simple.py1079–1108Critical Illness covered conditions
sources_simple.py1166–1173Hospital Indemnity Other Conditions (incl. dedicated Substance Abuse)
sources_simple.py1327–1363STD per-class structure
sources_simple.py1446–1500Vision schema (hardcoded networks; OON Discount fields absent)
sources_simple.py1646–1785Voluntary Life rates structure (Unismoker)
sources_simple.py2751–2775Group Life Contribution Type enum
About this audit

How it's built and how to refresh it

How v2 differs from v1

How to refresh

Caveats