OutputFixer (fixer.py)

On this page

Overview

OutputFixer repairs malformed LLM output through a hierarchy of four strategies, attempted in order, stopping at the first one that produces parseable text. Depends on schema.py (FixStrategy, OutputFormat) and the standard library (json, re).

Module-level repair helpers

These are stateless functions used internally by OutputFixer (not classes, no self):

FunctionBehavior
_strip_markdown_fences(text) -> strExtracts inner content of a ``` fence via regex; if no fence, returns text.strip().
_fix_json_quotes(text) -> strReplaces unescaped ' with " via re.sub(r"(?<![\\])'", '"', text). Caution: this is a blunt global substitution — it will also corrupt apostrophes inside otherwise-valid string values (e.g. "it's fine" → "it"s fine"), which can make already-valid JSON invalid.
_fix_trailing_commas(text) -> strRemoves , immediately before } or ].
_fix_unquoted_keys(text) -> strRegex-quotes bare object keys: {key: value} → {"key": value}.
_truncate_to_valid_json(text) -> Optional[str]Finds the first { or [ opener, then tries json.loads() on progressively shorter prefixes (from full length down to 1 character) until one parses. Returns the first successful prefix, or None if none parse. Handles LLM outputs that start valid then trail off/get cut short.
_extract_json_block(text) -> Optional[str]Uses json.JSONDecoder().raw_decode scanning from every {/[ occurrence to find the first complete, valid JSON value anywhere in the text (not just at the start).
_parse_key_value_fallback(text) -> Optional[Dict]Regex-extracts key: value / key = value lines (including **bold** markdown keys), lowercases and underscores keys, strips surrounding quotes from values. Returns None if nothing matched.

OutputFixer

Constructor

Python · 5 lines
OutputFixer(
    required_fields: Optional[List[str]] = None,
    field_defaults: Optional[Dict[str, Any]] = None,
    llm_fn: Optional[Callable[[str], str]] = None,
)
NameRequiredDefaultNotes
required_fieldsNo[]Field names that must be present for FIELD_INJECTION/FALLBACK_PARSE to inject/fill.
field_defaultsNo{}Default value to inject per missing field name. Fields with no entry here get None.
llm_fnNoNoneEnables LLM_REFORMAT strategy — without it, that strategy is skipped entirely.

_JSON_REPAIR_PIPELINE (class attribute) — an ordered list applied in sequence during REGEX_REPAIR: [_strip_markdown_fences, _fix_trailing_commas, _fix_json_quotes, _fix_unquoted_keys].

Public Methods

fix

Python
def fix(self, text: str, expected_format: OutputFormat = OutputFormat.JSON) -> Tuple[str, FixStrategy]

Attempts to repair text. Returns (repaired_text, strategy_used) — the caller is expected to re-parse repaired_text; fix() itself never parses into structured data, only returns cleaned-up text.

Internal workflow (stops at the first strategy that reports success, i.e. returns something other than FixStrategy.NONE):

  1. Empty/whitespace input → immediately returns (text, FixStrategy.NONE).
  2. _try_regex_repair(text, expected_format).
  3. _try_field_injection(text).
  4. _try_fallback_parse(text).
  5. _try_llm_reformat(text, expected_format) — only attempted if self.llm_fn is set.
  6. If nothing worked, logs a warning and returns (text, FixStrategy.NONE) — the original, unmodified text.

Raises: Does not raise on its own — all internal strategy attempts are wrapped so a failed strategy just falls through to the next one rather than propagating an exception. (Note: _try_llm_reformat catches exceptions from self.llm_fn itself via a broad except Exception.)

Side effects: logger.info at the start; logger.info on each strategy success; logger.warning if all strategies exhaust; if LLM_REFORMAT is attempted, makes a network/API call via self.llm_fn.

Python · 4 lines
fixer = OutputFixer(required_fields=["answer", "sources"])
fixed_text, strategy = fixer.fix("{answer: 'Paris', sources: ['wiki'],}", expected_format=OutputFormat.JSON)
# fixed_text == '{"answer": "Paris", "sources": ["wiki"]}'
# strategy == FixStrategy.REGEX_REPAIR

fix_dict

Python
def fix_dict(self, data: Dict[str, Any]) -> Tuple[Dict[str, Any], FixStrategy]

Operates on an already-parsed dict rather than raw text. If self.required_fields is empty, returns (data, FixStrategy.NONE) unchanged. Otherwise finds fields that are missing or explicitly None, injects self.field_defaults.get(f, None) for each, and returns (fixed_dict, FixStrategy.FIELD_INJECTION) — or (data, FixStrategy.NONE) if nothing was missing.

Python · 3 lines
fixer = OutputFixer(required_fields=["answer", "confidence"], field_defaults={"confidence": 0.5})
fixed, strategy = fixer.fix_dict({"answer": "Paris"})
# fixed == {"answer": "Paris", "confidence": 0.5}

Private strategy methods

_try_regex_repair

For expected_format == OutputFormat.JSON: runs the full _JSON_REPAIR_PIPELINE in sequence, then tries json.loads(). If that fails, tries _extract_json_block() on the pipeline output. If that fails too, tries _truncate_to_valid_json(). Returns the first result that's actually valid JSON, tagged REGEX_REPAIR.

For any other expected_format: only strips markdown fences (no JSON-specific repairs applied) — returns REGEX_REPAIR only if stripping actually changed something and the result is non-empty.

_try_field_injection

Only meaningful if required_fields is non-empty. Tries json.loads(_extract_json_block(text) or text); if that yields a dict, delegates to fix_dict() and, if it made changes, re-serializes with json.dumps(fixed, ensure_ascii=False).

_try_fallback_parse

Calls _parse_key_value_fallback(text). If it found anything, injects defaults for any required_fields still missing, then json.dumps()s the result, tagged FALLBACK_PARSE.

_try_llm_reformat

Builds a reformat prompt embedding: the required-fields hint (if any), a human-readable format name ("valid JSON object" / "valid YAML" / "CSV with header row" / "structured text" fallback via a hardcoded dict keyed by OutputFormat), and the full malformed text wrapped in --- BEGIN MALFORMED OUTPUT --- ... --- END --- markers. Calls self.llm_fn(prompt); if the response is non-empty after stripping, returns it tagged LLM_REFORMAT. Catches any exception from self.llm_fn and falls through to (text, FixStrategy.NONE).

build_answer_fixer

Python
def build_answer_fixer(llm_fn: Optional[LLMCallable] = None) -> OutputFixer

Pre-configured for AnswerSchema: required_fields=["answer"], field_defaults={"answer": "", "sources": [], "confidence": 0.5}.

Complexity

_try_regex_repair's _truncate_to_valid_json fallback is the worst-case cost center: up to O(n) json.loads() calls on shrinking prefixes, each of which is itself O(k) in the prefix length — O(n²) worst case for pathological near-valid JSON. All other strategies are O(n) single-pass regex operations. Space is O(n) for text copies made at each pipeline stage.

Thread safety

Safe to share a single OutputFixer instance across threads for concurrent fix()/fix_dict() calls — no mutable state beyond the read-only required_fields/field_defaults/llm_fn set at construction. If llm_fn itself is not thread-safe (e.g. a non-thread-safe HTTP client), that constraint is inherited by concurrent LLM_REFORMAT calls.

Side effects

  • Regex strategies: none beyond logging.
  • LLM_REFORMAT: makes an external call via llm_fn — network/API cost, latency, and any side effects your llm_fn implementation has.

Best practices

  • Order matters: only pass an llm_fn if you're comfortable with the cost/latency of a reformat call as a last resort — it's tried only after all free, local strategies fail.
  • If your expected format is consistently JSON, set required_fields even if you're not using OutputFixer standalone — the orchestrator's _build_fixer() derives this automatically from OutputParser(fields=...).

Common mistakes

  • Assuming _fix_json_quotes is safe on all text — it globally replaces every unescaped single quote, including ones inside legitimate string content (contractions like "it's", possessives, etc.), which can introduce new JSON errors rather than fixing them. This is a known sharp edge of the regex-repair strategy, not a bug to "fix" in your own code — just be aware it can behave unexpectedly on prose-heavy JSON string values.
  • Expecting fix() to return parsed data — it returns text. You must re-parse the returned string yourself (this is exactly what OutputParser._attempt_fix_and_parse does internally).

Version History

Not determinable from source.

Related pages