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.
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.
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:
@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:
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
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:
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.
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.