Custom Parsers¶
A parser in PsychScanner is a Pydantic BaseModel subclass.
It tells the LLM what fields to return and enforces type constraints on every value.
This guide covers: 1. Why use a parser? 2. Anatomy of a parser 3. Writing your first parser 4. Field types and constraints 5. Registering a parser (optional) 6. Per-trial routing with multiple parsers 7. Inspecting and debugging parsers 8. Common patterns
1. Why use a parser?¶
Without a parser, the model returns a raw AIMessage whose .content is an
unstructured string — anything from "4" to a paragraph of reasoning.
With a parser, ExpCard calls LangChain's with_structured_output().
The model is forced to emit valid JSON that matches the schema, and the result
arrives as a Python dict ready for analysis:
# Without parser
{"pred_resp": AIMessage(content="I'd say about 4 on that scale.")}
# With DefaultLiteralAgree parser
{"pred_resp": {"rating": 4}}
2. Anatomy of a parser¶
from pydantic import BaseModel, Field
from typing import Literal
class MyParser(BaseModel):
"""<-- class docstring becomes the top-level schema description shown to the model -->"""
field_name: SomeType = Field(
..., # required (no default)
description="<-- shown to the model as the field's instruction -->",
)
Key decisions:
| Decision | Effect |
|---|---|
| Class docstring | Sent as the overall task instruction for this parser |
| Field name | The key in the returned dict |
| Field type | Enforced by Pydantic — model output is coerced or rejected |
description |
Sent verbatim in the JSON schema — guide the model clearly |
3. Writing your first parser¶
A simple agreement rating on a 1–7 scale:
from pydantic import BaseModel, Field
from typing import Literal
class Agreement7(BaseModel):
"""Rate your agreement with the statement on a scale from 1 to 7."""
rating: Literal[1, 2, 3, 4, 5, 6, 7] = Field(
...,
description=(
"Agreement rating: "
"1 = strongly disagree, 7 = strongly agree. "
"Always give a single integer."
),
)
Wire it into an experiment card:
from psychscanner import ExpCardInit, ExpCard, ScannerModel
card = ExpCardInit(
model="smollm2:360m-instruct-fp16",
family="ollama",
parameters={"temperature": 0},
parser=Agreement7, # pass the class directly
task_file="my_survey.json",
cogtype="no",
nsim=1,
)
results = ScannerModel(expcard=ExpCard(card)).run()
# results[0][0]["pred_resp"] → {"rating": 5}
4. Field types and constraints¶
Categorical fields — Literal¶
Use Literal when the model must choose from a fixed set of options.
List the options in order of how you want the model to encounter them — LLMs sometimes
favour the first option in ambiguous cases.
from typing import Literal
source: Literal["internal", "external", "new"] = Field(...)
sentiment: Literal["positive", "neutral", "negative"] = Field(...)
Bounded continuous fields — float with ge/le¶
rating: float = Field(..., ge=0.0, le=100.0,
description="Relatedness from 0 (not related) to 100 (highly related).")
Or with confloat (deprecated in Pydantic v2 — prefer Field(ge=, le=) instead):
Integer Likert scales — Literal of ints¶
confidence: Literal[1, 2, 3, 4, 5, 6] = Field(
..., description="1 = not at all confident, 6 = highly confident."
)
Using Literal instead of int prevents out-of-range integers without writing
a custom validator.
Free-text fields — str¶
Multi-field parsers¶
Combine fields freely:
class EncodingResponse(BaseModel):
"""Respond to the encoding trial."""
word: str = Field(..., description="The second word (given or imagined).")
relatedness: float = Field(..., ge=0.0, le=100.0,
description="Relatedness of the two words (0–100 %).")
5. Registering a parser (optional)¶
Bundled parsers are auto-registered in PARSER_REGISTRY at import time.
Custom parsers do not need to be registered — you can always pass the class directly.
If you want to reference your parser by name string (e.g. in a task JSON file or via get_parser()),
add it to the registry:
from psychscanner.parsers import PARSER_REGISTRY
from my_parsers import Agreement7
PARSER_REGISTRY["Agreement7"] = Agreement7
# Now these all work:
from psychscanner.parsers import get_parser
cls = get_parser("Agreement7")
# And in task JSON:
# { "parser": "Agreement7" }
# with card.parser = "1"
Do this registration before creating ExpCard.
6. Per-trial routing with multiple parsers¶
When a task has different trial types that need different parsers, you have two options.
Option A — callable dispatch (recommended for code)¶
from psychscanner.parsers import Response_part_1_rm, Response_part_2_rm
def rm_dispatch(trcode: str):
if "test" in trcode:
return Response_part_2_rm # Judgment + Confidence
return Response_part_1_rm # Word_2 + Rating
card = ExpCardInit(parser=rm_dispatch, ...)
The callable is called once per trial with the trial's trcode string and must
return a BaseModel subclass (or None for no structured output on that trial).
Option B — "parser" key in the task JSON (Form A)¶
Add a "parser" field to each trial dict in your task JSON file.
The value must be a registered parser name string.
Set card.parser = "0" as the card-level fallback:
{
"chain_type": "item",
"parser": "0",
"items": {
"encode_1": [{"trcode": "encode_1", "parser": "Response_part_1_rm", "stimulus": {...}}],
"test_1": [{"trcode": "test:encode_1", "parser": "Response_part_2_rm", "stimulus": {...}}]
}
}
Priority: per-trial JSON "parser" (Option B) takes precedence over the card-level
callable (Option A). You can mix them: use the callable as a fallback for trials that
don't specify a parser in the JSON.
7. Inspecting and debugging parsers¶
View the JSON schema¶
The schema is what gets sent to the model as the structured-output spec:
Keep the raw AIMessage alongside parsing¶
Set parser_raw=True to get both the parsed dict and the original model output:
card = ExpCardInit(parser=Agreement7, parser_raw=True, ...)
# trial["pred_resp"] now contains {"rating": 5, "_raw": AIMessage(...)}
Change the structured-output method¶
parser_config is forwarded to model.with_structured_output(**parser_config):
card = ExpCardInit(
parser=Agreement7,
parser_config={"method": "function_calling"}, # try if json_schema fails
...
)
Default is {"method": "json_schema"}. Use "function_calling" or "json_mode"
if your provider doesn't support JSON schema mode.
8. Common patterns¶
Confidence + rating pair¶
class RatingConf(BaseModel):
"""Rate and express confidence."""
rating: Literal[1, 2, 3, 4, 5] = Field(
..., description="Rating: 1 (low) to 5 (high).")
confidence: Literal[1, 2, 3, 4, 5, 6] = Field(
..., description="Confidence: 1 (guessing) to 6 (certain).")
Source judgment (reality monitoring)¶
class SourceJudgment(BaseModel):
"""Judge the origin of the item."""
source: Literal["internally generated", "externally presented", "new"] = Field(
..., description="Was the item imagined, perceived, or new?")
confidence: Literal[1, 2, 3, 4, 5, 6] = Field(
..., description="Confidence in the judgment: 1–6.")
Binary yes/no with explanation¶
class YesNoExplain(BaseModel):
"""Answer yes or no and briefly explain."""
answer: Literal["yes", "no"] = Field(...)
reason: str = Field(..., description="One sentence explaining the answer.")
Multi-step trial (per-step parsers)¶
Split complex trials into separate steps using a callable dispatch:
def step_dispatch(trcode: str):
if "step2" in trcode:
return StepTwoParser
return StepOneParser
card = ExpCardInit(parser=step_dispatch, ...)
See also¶
- Parsers API Reference — full class listing
- Example notebooks — Parsers, Parser System Guide
- Reality Monitoring task guide