WARNING: NOT AN INVESTMENT TOOL. This is a technical demonstration of multi-agent AI systems. All output is AI-generated and must not be used as financial advice.
Architecture Pattern

LLM Output Validation: Enforcing Structured Output with Pydantic Schemas

Two independent validation layers for LLM output: strict Pydantic schemas that guard structural shape immediately after every model call, and deterministic business-rule checks that catch semantically-wrong-but-structurally-valid output at the end of the pipeline.

What it is

LLMs are unreliable narrators of structured data — a model can return syntactically valid JSON that's still logically wrong (a stop-loss above the entry price, allocations that don't sum to 100%). Output validation as an architecture pattern means never trusting raw LLM output directly: every phase's result is parsed into a strict schema immediately, and separately, business-rule logic re-checks the final assembled output against domain invariants a schema alone can't express.

How ProspectAI implements it: Layer 1, schema validation

Every one of ProspectAI's six pipeline phases validates its LLM output against a Pydantic model the moment the call returns, via _extract_pydantic(). If parsing fails, the pipeline halts immediately rather than propagating malformed data downstream:

ProspectAI / prospect_ai_flow.py, lines 190-212
@staticmethod
def _extract_pydantic(result: CrewOutput, model_cls: Type[_M], phase: str) -> _M:
    """Return validated Pydantic model from mini-Crew result.

    Tries result.tasks_output[0].pydantic first (set by CrewAI when
    output_pydantic validation succeeds), then falls back to parsing
    result.raw directly via model_validate_json.
    """
    pydantic_model = (
        result.tasks_output[0].pydantic
        if result.tasks_output
        else None
    )
    if pydantic_model is not None:
        return cast(_M, pydantic_model)
    raw = result.raw or ""
    try:
        return cast(_M, model_cls.model_validate_json(raw))
    except Exception as exc:
        raise RuntimeError(
            f"Pydantic validation failed for {phase}. "
            f"raw[:200]={raw[:200]!r}"
        ) from exc

The schemas themselves encode cross-field invariants, not just types. TradeSetup requires that a LONG-BUY's stop-loss sits below its entry zone, which in turn sits below its take-profit — expressed as a Pydantic model_validator that raises if the ordering is violated:

ProspectAI / schemas/agent_outputs.py, lines 147-178
class TradeSetup(BaseModel):
    direction: Literal["LONG-BUY"]
    entry_zone_low: float = Field(..., gt=0)
    entry_zone_high: float = Field(..., gt=0)
    stop_loss: float = Field(..., gt=0)
    take_profit: float = Field(..., gt=0)

    @model_validator(mode="after")
    def validate_long_trade_structure(self) -> TradeSetup:
        if not (self.stop_loss < self.entry_zone_low):
            raise ValueError(
                f"LONG-BUY invariant violated: "
                f"stop_loss ({self.stop_loss}) must be < "
                f"entry_zone_low ({self.entry_zone_low}). "
                f"Required: stop_loss < entry_zone_low "
                f"<= entry_zone_high < take_profit."
            )
        ...
        return self

Layer 2: deterministic business-rule checks

Schema validation alone isn't enough — a trade setup can satisfy every Pydantic constraint and still be a bad recommendation (e.g. a stop-loss that's technically below entry but by a trivially small margin). recommendation_validator.py runs once at the very end of the pipeline, over the fully assembled output, checking plain Python business logic rather than schema shape:

ProspectAI / utils/recommendation_validator.py, lines 22-47
def validate_position(position: Dict[str, Any]) -> List[ValidationIssue]:
    """Validate a single position dict from InvestorStrategicOutput.positions."""
    issues: List[ValidationIssue] = []
    ticker  = position.get("ticker", "UNKNOWN")
    action  = (position.get("action") or "").upper()
    setup   = position.get("trade_setup") or {}
    ...
    # -- TradeSetup invariant (should be caught by Pydantic, but belt-and-suspenders) --
    if stop_loss is not None and entry_low is not None:
        if stop_loss >= entry_low:
            issues.append(ValidationIssue(
                severity="critical",
                ticker=ticker,
                field="trade_setup.stop_loss",
                message=(
                    f"stop_loss ({stop_loss}) >= entry_zone_low ({entry_low}) -- "
                    "stop will trigger immediately on entry."
                ),
            ))

The code's own comment — "should be caught by Pydantic, but belt-and-suspenders" — makes the intent explicit: this isn't redundant, it's defense-in-depth against LLM unreliability at two different levels of the pipeline.

Why it matters

Separating "does this parse into the right shape" (schema) from "does this make business sense" (deterministic rules) keeps each concern simple and testable independently. A schema change doesn't need business-rule changes and vice versa, and — critically for a system whose output feeds directly into the same portfolio strategy that gets adversarially reviewed (see adversarial critic) — a validation failure at either layer halts the pipeline rather than shipping a recommendation nobody checked.

Related Patterns