Replace manual JSON parsing with Instructor for structured output reliability#339
Conversation
|
@pegahmansourian Thanks for this — moving to structured output (instructor + Pydantic) is a solid direction and it removes a whole class of manual-parsing bugs. A few things to address before we can take it:
Minor/practical: instructor isn't in requirements.txt (install will break), and the branch now conflicts with main — will need a rebase. |
…iability
- Add llm_structured() and llm_astructured() in utils.py using instructor.from_litellm()
- Add Pydantic models for all 11 structured outputs in page_index.py
- Replace all extract_json() calls with typed Instructor calls
- Remove 'Directly return the final JSON structure' from all prompts
- Fixes silent KeyError crashes when extract_json() returned {} on malformed LLM output
# Conflicts:
# pageindex/page_index.py
# pageindex/utils.py
# Conflicts:
# pageindex/page_index.py
… Pydantic
Introduce structured output via Instructor + LiteLLM + Pydantic
across the PDF indexing pipeline, removing manual JSON parsing and
its associated failure modes.
- Extract all 14 response schemas into pageindex/schemas.py
(previously inline in page_index.py); no changes to field
descriptions, validation_alias configuration, or defaults during
the move
- Add AliasChoices("thinking", "reason", "explanation", "rationale")
to reasoning fields so models using a synonym key still validate
- Fix TOCIndexItem.structure: Optional[str] alone does not make a
Pydantic v2 field omittable; add default=None so responses
omitting the key validate instead of failing with "Field required"
- Guard add_page_offset_to_toc_json against offset=None (raised
when calculate_page_offset has no matching_pairs to compute
from), replacing an opaque TypeError with a descriptive ValueError
- Add instructor>=1.13.0,<2.0.0 to requirements.txt
Adds tests/test_instructor_migration.py (23 tests) covering the reliability properties of the Instructor/Pydantic migration: - TOCIndexItem.structure regression test (Optional[str] without default=None is not omittable in Pydantic v2) - add_page_offset_to_toc_json guard against offset=None - llm_structured/llm_astructured: finish_reason truncation detection, max_tokens threading, max_retries capped at 1, litellm/ prefix stripping - toc_transformer end-to-end behavior against the migrated implementation - AliasChoices synonym handling for reasoning fields Also includes the llm_structured/llm_astructured implementation changes these tests cover: switched from create() to create_with_completion() to expose finish_reason, added max_tokens parameter, capped max_retries at 1 (previously 3 — found to compound failures on smaller models by feeding accumulated failed completions back into retry context). tests/test_issue_163.py is left unmodified. Running it against this branch shows 10 failed / 4 passed: the 4 passes (extract_toc_content) are unaffected since that function wasn't migrated; the 10 failures mock llm_completion for functions now using llm_structured, so the mock no longer intercepts the real call path. Not a regression — a consequence of those tests asserting on manual-parsing implementation details this PR removes. Left as-is pending maintainer input on how to handle it.
b4a5886 to
4bb25b5
Compare
|
Thanks again for the detailed review — here's where things stand now: 1. Schemas moved. Done — all 14 2. Per-field guidance preserved. Confirmed unchanged from the original inline prompts; every field's class PageIndexDetection(BaseModel):
thinking: str = Field(
validation_alias=AliasChoices("thinking", "reason", "explanation", "rationale"),
description="why do you think there are page numbers/indices given within the table of contents"
)
page_index_given_in_toc: Literal["yes", "no"] = Field(...)I also added 3. Test data. I don't have a full multi-PDF, multi-model parse-failure-rate benchmark. I don't have budget for paid API access at the scale that would be needed (GPT-4o, etc.), so I can't produce the complete hosted-vs-local comparison you'd ideally want, and I want to be upfront about that rather than let the gap go unaddressed. What I do have:
I also ran real (non-mocked) testing against one PDF (NVIDIA's FY26 Q3 10-Q, 44 pages) using a local model via Ollama ( Minor/practical:
While working through that, I found a related gap worth calling out rather than quietly leaving: One deliberate trade-off I'm leaving as digestible-but-unresolved: your original Let me know if you'd like the completeness-check validator work done in this PR before another look, or if you'd rather review the current state first. |
Problem
The codebase uses manual JSON prompt instructions across 11 locations in
page_index.py, with a customextract_json()parser inutils.pythathandles malformed output through multiple fallback strategies.
When the LLM returns slightly malformed JSON,
extract_json()silentlyreturns
{}, causing downstreamKeyErrorcrashes like:Solution
Replace manual JSON parsing with Instructor,
which is patched directly onto the existing LiteLLM client already used in the codebase.
Changes
utils.pysync_instructor_clientandasync_instructor_clientpatched viainstructor.from_litellm()llm_structured()for sync structured callsllm_astructured()for async structured callsextract_json()andget_json_content()can be removed once all callers are updatedpage_index.pyextract_json()calls withllm_structured()/llm_astructured()"Directly return the final JSON structure"from all prompts — Instructor handles this automaticallyBenefits
{}returnsLiteral["yes", "no"]fields are always validinstructoris the only new dependencyNew dependency
This PR adds one new dependency:
instructorAdd to
requirements.txt:instructorOr install directly: