Question Issuance and Resolution Flow¶
This document provides a comprehensive overview of the question issuance and resolution system, including all models, helper methods, and the complete process flow.
Table of Contents¶
Overview¶
The question issuance and resolution system allows for dynamic question generation based on live sports events. Questions are defined as templates, issued when specific conditions are met, and resolved when resolution conditions are satisfied. The system supports:
- Dynamic question text using JSONPath placeholders
- State extraction from issuance events for later resolution
- Multiple choice questions with dynamic answer generation
- Condition-based resolution using JSONPath expressions
- Multiple user answers per question issuance
Important: The question engine is flexible with event structure. Event data is passed directly from the event source (e.g., SportRadar) without format conversion. JSONPath expressions in question definitions are written to navigate the actual event structure. Examples in this documentation are illustrative only - the actual event structure depends on the event source.
Models¶
QuestionDefinition¶
The template for questions. Defines when questions can be issued, what state to extract, and how to resolve them.
Key Fields:
question_text: Template text with JSONPath placeholders (e.g.,"Will {{$.store_state.random_team_name}} score a goal?")- per-choice
outcome_text: On-field line when that choice wins issuance_trigger_condition_list: Conditions that must be met to issue the questionissuance_state_extract: Dictionary mapping keys to JSONPath patterns for extracting state from issuance eventsanswer_choices_template: Template for generating answer choices (optional)resolution_trigger_template: Conditions that must be met to resolve the question (can be auto-derived from answer choices)resolution_correction_window_seconds: Seconds to wait for a same-eidcorrective before resolving (0 = immediate unless the play is under review); measured perLISTENER_TIMING_SOURCE(realtimeor streamts). The window does not expire while the play is under review.issuance_correction_window_seconds: Seconds to wait for a same-eidcorrective before creating an issuance (0 = immediate create)issuance_start_on_corrective: When true, a corrective may start a deferred or immediate issue when triggers newly matchissuance_retract_on_corrective: When true, a same-eidcorrective that no longer matches issuance triggers closes an open issuance asretracted
Key Methods:
is_issuable(issuance_event): Checks if question can be issued based on eventextract_issuance_state(issuance_event): Extracts state data from issuance eventgenerate_answer_choices(issuance_event): Generates formatted answer choices from templateget_resolution_conditions(): Returns resolution conditions (auto-derives from answer choices if template is empty)
QuestionIssuance¶
An instance of a question that has been issued. Stores the specific event that triggered issuance, extracted state, and resolution information.
Key Fields:
question_definition_id: Reference to the QuestionDefinitionmatch_id: Internal match id (Match.id) this question is for — not the Sportradar match idissuance_event: The event that triggered the question issuancestore_state: Extracted state data needed for resolutionanswer_choices: Generated answer choices for this specific issuancedefinition_snapshot: JSON copy of the question definition at issue time (QuestionDefinitionOutshape). Null for legacy rows issued before snapshots existed. Ops display and resolution (is_resolvable, outcome copy templates) prefer this over the live definition row. Selection of which definition to issue next still uses live active definitions.question_settings_snapshot: Effective in-game question settings (max, interval, points, period distribution, answer window) plus override/league sources at issue time. Null for legacy rows. Ops “At play” UI uses this; Current in-game question settings on match details remains live.resolution_event: The event that resolved the question (empty if not resolved)resolved_at: Timestamp when question was resolved (None if not resolved)
Audit layers (current vs at-issue vs at-run):
| Layer | Where | Purpose |
|---|---|---|
| Current | Match details “Current in-game question settings”, live definition rows | What is configured now |
| At issue | definition_snapshot, question_settings_snapshot |
What was true when the question was issued / how it resolves |
| At run | MatchListenerRecord.startup_params |
What that listener process actually used |
Key Methods:
create_from_issuance(question_definition, issuance_event, match_id, *, match=None, **kwargs): Factory method to create a new issuance (pinsdefinition_snapshot; whenmatchis passed, also pinsquestion_settings_snapshot; stampsissued_in_period,issued_situation, andissued_clockfrom the event viaextract_period_from_event/extract_situation_from_event/extract_clock_from_event)is_resolvable(resolution_event): Checks if this issuance can be resolved by the event using pinned snapshot conditions when presentget_resolution_conditions(): Conditions from snapshot or live definition (legacy null-snapshot rows only)resolve_with_event(resolution_event): Resolves the question with a resolution event. Setsresolution_eventandresolved_at, evaluates all answer choices'correct_conditionfields, and setsis_correcton each answer choiceis_resolved(): Returns True if question has been resolvedget_formatted_question_text(*, require_snapshot=False): Formatted question text (snapshot template when present; withrequire_snapshot=Truefor ops, returns—instead of using the live template)get_formatted_success_text()/get_formatted_fail_text(): Legacy snapshot templates only (empty when the pin has nosuccess_text/failed_text). Play, history, and push notifications compose viaLeague.render_resolve_copywith stampedresolve_copyand winningoutcome_text.definition_is_snapshot: True whendefinition_snapshotwas pinned at issue time
QuestionAnswer¶
Stores a user's answer to a specific question issuance. Multiple users can answer the same question.
Key Fields:
question_issuance_id: Reference to the QuestionIssuanceuser_id: The user who provided this answerselected_answer_identifier: The identifier of the answer choice selectedanswered_at: When the user provided this answeris_correct: Whether this answer was correct (set when question is resolved)
Key Methods:
evaluate_correctness(resolution_event): Returns whether this answer is correct by reading theis_correctflag from the selected answer choice. The flag is set duringQuestionIssuance.resolve_with_event(). Theresolution_eventparameter is kept for backward compatibility but is not used.
Helper Methods¶
JSONPath Utilities (lib/utils.py)¶
jsonpath_extract(pattern, data)¶
Extracts a value from a data dictionary using JSONPath patterns or function calls.
Supported Patterns:
- JSONPath:
"$.event.type","$.team.name","$.store_state.random_team_id" rand()function:"rand({{$.team1}}, {{$.team2}})"— randomly selects from optionsto_sec()function:"to_sec({{$.payload.event.clock}})"—mm:ss→ secondsif_eq()function:"if_eq({{$.payload.event.inning_half}}, 'T', {{$.payload.game.away.name}}, {{$.payload.game.home.name}}) ~ batting team"— if arg0 equals arg1 return arg2, else arg3 (trailing~default if extract fails)- Default values:
"$.path ~ default"— uses default if extraction fails (helpers: trailing form only, e.g."to_sec({{$.clock}}) ~ 0")
Examples:
jsonpath_extract("$.event.type", {"event": {"type": "goal"}})
# Returns: "goal"
jsonpath_extract("rand({{$.team1}}, {{$.team2}})", {"team1": "Bruins", "team2": "Rangers"})
# Returns: "Bruins" or "Rangers" (randomly)
jsonpath_extract(
"if_eq({{$.half}}, 'T', {{$.away}}, {{$.home}})",
{"half": "T", "away": "Diamondbacks", "home": "Marlins"},
)
# Returns: "Diamondbacks"
jsonpath_replace(template, data)¶
Replaces JSONPath placeholders in a template string with extracted values.
Placeholder Format:
{{$.path.to.value}}- standard placeholder{{$.path.to.value ~ default}}- with default value
Examples:
jsonpath_replace("Will {{$.store_state.random_team_name}} score?", {
"store_state": {"random_team_name": "Bruins"}
})
# Returns: "Will Bruins score?"
jsonpath_condition_evaluate(condition, data)¶
Evaluates a boolean condition containing JSONPath expressions.
Condition Format:
- JSONPath expressions wrapped in
{{ }}:{{$.event.type}} - Comparison operators:
==,!=,>,<,>=,<= - Boolean operators:
and,or,not(or&&,||,!) - Parentheses for grouping
Examples:
jsonpath_condition_evaluate(
'{{$.event.type}} == "goal" && {{$.event.attribution.team.id}} == {{$.store_state.random_team_id}}',
{
"event": {"type": "goal", "attribution": {"team": {"id": "123"}}},
"store_state": {"random_team_id": "123"}
}
)
# Returns: True
Process Flow¶
sequenceDiagram
participant EventStream as Event Stream
participant QD as QuestionDefinition
participant QI as QuestionIssuance
participant User as User
participant QA as QuestionAnswer
participant Resolver as MatchListener
participant Celery as AnswerEvalTasks
Note over EventStream,Resolver: 1. ISSUANCE PHASE
EventStream->>QD: Event arrives (e.g., stoppage)
QD->>QD: select_by_league (filter issuable, then weighted pick)
QD->>QD: issuance_probability gate (optional pity timer)
alt Selected and probability gate passes
QD->>QI: create_from_issuance(definition, event, match_id)
QI->>QD: extract_issuance_state(issuance_event)
QD->>QD: jsonpath_extract() for each state field
QD-->>QI: store_state = {random_team_id: "...", ...}
QI->>QD: generate_answer_choices(issuance_event)
QD->>QD: jsonpath_replace() for each choice text
QD-->>QI: answer_choices = [{identifier: "yes", ...}, ...]
QI->>QI: Save to database
QI-->>User: Question displayed with formatted text
end
Note over EventStream,Resolver: 2. ANSWER PHASE
User->>QA: Submit answer (selected_answer_identifier)
QA->>QA: Save to database
Note over EventStream,Resolver: 3. ISSUANCE RESOLUTION
EventStream->>Resolver: Resolution event arrives (e.g., goal)
Resolver->>QI: is_resolvable(resolution_event)
QI->>QD: get_resolution_conditions()
QD->>QD: jsonpath_condition_evaluate() for each condition
QD-->>QI: True/False
alt Question is resolvable
Resolver->>QI: resolve_with_event(resolution_event)
QI->>QI: Set resolution_event and resolved_at
QI->>QI: For each answer choice:
QI->>QI: jsonpath_condition_evaluate(correct_condition)
QI->>QI: Set choice.is_correct
Resolver->>QI: Persist issuance commit
end
Note over Resolver,Celery: 4. ANSWER EVALUATION
Resolver->>Celery: evaluate_question_answers_batch (chunked answer IDs)
Celery->>QA: Load rows, evaluate_correctness, points, resolved_at
QA->>QI: Read answer_choices.is_correct for selected choice
QA-->>User: Per-user scoring complete
Flow Steps¶
- Issuance Phase:
- Event arrives from event stream
- Match-wide gates (
question_max_count, period distribution): when a gate is closed, onlyis_pivotal_momentdefinitions remain in the pool. Pivotal skip of the match period map does not skip a definition’sissuance_period_distributionor the group period map (unlisted: blocktreats omitted keys as 0). QuestionDefinition.select_by_league(league_id, event)picks one active definition that is issuable for the event, whoseissuance_grouphas no unresolved issuance and whose group wait has elapsed (max(match floor, live group interval, last issuance snapshot), plus any wait after other groups issue), using effective weights: baseissuance_weight, optionally decayed byissuance_weight * (issuance_weight_decay ** times_issued)whenissuance_dynamic_weightingis on (session counts on the listener). The listener logs the full candidate name/short-id + weight vector before the pick. If any eligible candidate hasis_pivotal_moment, selection is restricted to that subset (still one issue per event).- Selected definition’s
issuance_probabilitygate runs once (optionalissuance_probability_max_false_streakpity timer); a false skip means no issuance on that event (no re-pick among other eligible definitions) and does not update dynamic weights - On a successful issue with dynamic weighting on, the listener increments the session issue count and logs the old/new effective weight
QuestionIssuance.create_from_issuance()creates new issuance:- Copies
issuance_groupandissuance_min_intervalfrom the definition - Stamps
issued_event_tsfrom the issuance eventts(cross-group waits after issuance) - Stamps
issued_in_period,issued_situation, andissued_clockfrom the event (first-match extractors inlib.event_periodandlib.event_game_context; new leagues append an extractor) - Pins
definition_snapshot(JSON of the definition at issue time) - Pins
question_settings_snapshotwhen the match row is passed - Calls
extract_issuance_state()to extract state data - Calls
generate_answer_choices()to generate answer choices
- Copies
- Issuance saved to database
- Optional real-time pause
(
LISTENER_POST_ISSUANCE_PAUSE_SECONDS) before the next stream event — independent ofLISTENER_TIMING_SOURCE; for fast simulator playback -
Question displayed to users with formatted text
-
Answer Phase:
- Users submit answers via
QuestionAnswermodel -
Each answer is stored with
question_issuance_id,user_id, andselected_answer_identifier -
Issuance resolution:
- Resolution event arrives from event stream
QuestionIssuance.is_resolvable()checks if the issuance can be resolved (uses pinneddefinition_snapshotconditions when present; live definition only for legacy null-snapshot rows)- If resolvable and the definition’s
resolution_correction_window_secondsis 0,QuestionIssuance.resolve_with_event()runs immediately:- Sets
resolution_eventandresolved_at - Evaluates each answer choice's
correct_conditionwithjsonpath_condition_evaluate()and setschoice.is_correct
- Sets
- If the window is > 0, the listener holds the candidate in memory for
that many seconds (per
LISTENER_TIMING_SOURCE:realtimetimer, or streamtsdeltas for simulator replay). Same-eidcorrective re-issues update the candidate payload (they are still logged as skipped for general processing). When the window elapses, resolution uses the (possibly corrected) candidate. -
The listener commits the issuance so choice-level correctness is stored
-
Answer evaluation:
- The listener loads
QuestionAnswerIDs for that issuance and enqueues one or more Celeryevaluate_question_answers_batchtasks (IDs chunked byANSWER_EVALUATION_BATCH_SIZEfor workload sizing; each task scores every ID it receives) evaluate_question_answers_batch_implloads each answer, callsevaluate_correctness()(reads pre-computed choiceis_correct), setsQuestionAnswer.is_correct,points_awarded, andresolved_at, and commits
Complete Example¶
QuestionDefinition Example¶
QuestionDefinition(
id=UUID("123e4567-e89b-12d3-a456-426614174000"),
league_id=UUID("league-id"),
is_active=True,
question_text="Will {{$.store_state.random_team_name}} score a goal in the next 5 minutes?",
issuance_weight=1.0,
# Conditions that must be met to issue this question
issuance_trigger_condition_list=QuestionTriggerConditionList(
conditions=[
QuestionTriggerCondition(condition='{{$.event.type}} == "stoppage"')
]
),
# State to extract from issuance event (processed in order, later fields can reference earlier ones)
issuance_state_extract={
"random_team": "rand({{$.team1}}, {{$.team2}})", # Randomly select a team
"random_team_id": "$.store_state.random_team.id", # Extract ID from previously stored random_team
"random_team_name": "$.store_state.random_team.name", # Extract name from previously stored random_team
"start_time": "$.event.clock", # Extract clock time
"start_period": "$.period.number", # Extract period number
},
# Template for answer choices
answer_choices_template=AnswerChoiceList(
choices=[
AnswerChoice(
identifier="yes",
text="Yes",
correct_condition='{{$.event.type}} == "goal" && {{$.event.attribution.team.id}} == {{$.store_state.random_team_id}}',
),
AnswerChoice(
identifier="no",
text="No",
correct_condition='({{$.event.type}} == "goal" && {{$.event.attribution.team.id}} != {{$.store_state.random_team_id}}) || ({{$.period.number}} > {{$.store_state.start_period}})',
),
]
),
# Resolution conditions (empty = auto-derived from answer choices)
resolution_trigger_template=QuestionTriggerConditionList(conditions=[]),
)
Issuance Event Example¶
Note: The examples below show a simplified event structure for illustration purposes. In practice, the actual event structure depends on the event source (e.g., SportRadar). The question engine is flexible - JSONPath expressions in question definitions are written to navigate the actual event structure. No format conversion is needed.
# Example event structure (illustrative - actual structure varies by source)
issuance_event = {
"event": {
"type": "stoppage",
"clock": "10:30",
},
"period": {"number": 2},
"team1": {
"id": "team-1-uuid",
"name": "Bruins",
},
"team2": {
"id": "team-2-uuid",
"name": "Rangers",
},
}
Generated QuestionIssuance¶
After create_from_issuance() is called:
QuestionIssuance(
id=UUID("issuance-id"),
question_definition_id=UUID("123e4567-e89b-12d3-a456-426614174000"),
match_id=UUID("match-id"),
issuance_event={
"event": {"type": "stoppage", "clock": "10:30"},
"period": {"number": 2},
"team1": {"id": "team-1-uuid", "name": "Bruins"},
"team2": {"id": "team-2-uuid", "name": "Rangers"},
},
store_state={
"random_team": {"id": "team-1-uuid", "name": "Bruins"}, # Randomly selected
"random_team_id": "team-1-uuid",
"random_team_name": "Bruins",
"start_time": "10:30",
"start_period": 2,
},
answer_choices=[
{
"identifier": "yes",
"text": "Yes",
"correct_condition": '{{$.event.type}} == "goal" && {{$.event.attribution.team.id}} == {{$.store_state.random_team_id}}',
"is_correct": None,
},
{
"identifier": "no",
"text": "No",
"correct_condition": '({{$.event.type}} == "goal" && {{$.event.attribution.team.id}} != {{$.store_state.random_team_id}}) || ({{$.period.number}} > {{$.store_state.start_period}})',
"is_correct": None,
},
],
resolution_event={},
resolved_at=None,
)
Resolution Event Example¶
Note: This is an illustrative example. The actual event structure depends on the event source. JSONPath expressions in question definitions navigate the actual structure.
# Example resolution event structure (illustrative - actual structure varies by source)
resolution_event = {
"event": {
"type": "goal",
"clock": "8:15",
"attribution": {
"team": {
"id": "team-1-uuid", # Matches random_team_id
"name": "Bruins",
}
},
},
"period": {"number": 2},
}
User Answer Example¶
QuestionAnswer(
id=UUID("answer-id"),
question_issuance_id=UUID("issuance-id"),
user_id=UUID("user-id"),
selected_answer_identifier="yes",
answered_at=datetime(2024, 1, 15, 19, 10, 30),
is_correct=None, # Set in **Answer evaluation** (stage 4), not issuance resolution
)
After issuance resolution (Flow step 3)¶
After resolve_with_event() runs and the listener commits the issuance:
# QuestionIssuance updated:
resolution_event={
"event": {
"type": "goal",
"clock": "8:15",
"attribution": {"team": {"id": "team-1-uuid", "name": "Bruins"}},
},
"period": {"number": 2},
}
resolved_at=datetime(2024, 1, 15, 19, 15, 45)
# Answer choices on the issuance updated with is_correct flags:
answer_choices=[
{
"identifier": "yes",
"text": "Yes",
"correct_condition": '...',
"is_correct": True, # Evaluated during resolve_with_event()
},
{
"identifier": "no",
"text": "No",
"correct_condition": '...',
"is_correct": False, # Evaluated during resolve_with_event()
},
]
After answer evaluation (Flow step 4)¶
After evaluate_question_answers_batch / evaluate_question_answers_batch_impl score each QuestionAnswer:
# QuestionAnswer row (per user), e.g. selected "yes":
is_correct=True # "yes" was correct because random team scored
points_awarded=... # From await QuestionDefinition.get_question_points(db, match)
resolved_at=datetime(...) # When this row was scored
Method Call Sequence¶
Complete Flow with Method Calls¶
1. EVENT ARRIVES (issuance_event)
└─> QuestionDefinition.select_by_league(league_id, issuance_event)
└─> Filter active definitions with is_issuable(issuance_event)
└─> jsonpath_condition_evaluate() for each condition in issuance_trigger_condition_list
└─> jsonpath_extract() for each JSONPath expression in condition
└─> Weighted random among issuable defs with effective weight > 0
(base weight × optional session decay); log selection pool
└─> QuestionDefinition.decide_issuance_probability(false_counts)
└─> False → skip event (no re-pick); True / force → continue
2. CREATE ISSUANCE
└─> QuestionIssuance.create_from_issuance(question_definition, issuance_event, match_id)
├─> QuestionDefinition.extract_issuance_state(issuance_event)
│ └─> For each key in issuance_state_extract:
│ ├─> jsonpath_extract(pattern, context)
│ │ └─> If pattern is "rand(...)":
│ │ └─> jsonpath_extract() for each argument
│ │ └─> random.choice() to select value
│ └─> Store in store_state[key]
│
└─> QuestionDefinition.generate_answer_choices(issuance_event)
└─> For each choice in answer_choices_template:
├─> If choice.text_extract exists:
│ └─> jsonpath_extract(choice.text_extract, issuance_event)
└─> Else:
└─> jsonpath_replace(choice.text, issuance_event)
└─> jsonpath_extract() for each placeholder
3. DISPLAY QUESTION
└─> QuestionIssuance.get_formatted_question_text()
└─> jsonpath_replace(question_definition.question_text, context)
└─> jsonpath_extract() for each placeholder
4. USER SUBMITS ANSWER
└─> QuestionAnswer(question_issuance_id, user_id, selected_answer_identifier)
└─> Save to database
5. RESOLUTION EVENT ARRIVES
└─> QuestionIssuance.is_resolvable(resolution_event)
├─> QuestionDefinition.get_resolution_conditions()
│ ├─> If resolution_trigger_template.conditions is not empty:
│ │ └─> Return resolution_trigger_template
│ └─> Else (auto-derive from answer choices):
│ └─> Combine all correct_condition fields with OR
│ └─> Return QuestionTriggerConditionList with combined condition
│
└─> jsonpath_condition_evaluate() for each condition
└─> jsonpath_extract() for each JSONPath expression
└─> Evaluate boolean expression
6. RESOLVE ISSUANCE
└─> QuestionIssuance.resolve_with_event(resolution_event)
├─> Check if resolvable (is_resolvable())
├─> Set resolution_event and resolved_at
└─> For each answer choice:
├─> Create evaluation context (resolution_event + store_state)
└─> If choice.correct_condition exists:
└─> jsonpath_condition_evaluate(choice.correct_condition, context)
└─> jsonpath_extract() for each JSONPath expression
└─> Evaluate boolean expression
└─> Set choice.is_correct = result
└─> Listener persists issuance (commit)
7. EVALUATE ANSWERS (Celery, possibly multiple tasks)
└─> Listener enqueues evaluate_question_answers_batch(answer_ids per chunk)
└─> evaluate_question_answers_batch_impl(answer_ids)
└─> For each QuestionAnswer:
└─> QuestionAnswer.evaluate_correctness(resolution_event)
├─> Find selected choice in answer_choices
└─> Return choice.is_correct (pre-computed during resolve_with_event)
└─> Set QuestionAnswer.is_correct, points_awarded, resolved_at
└─> Commit batch
8. DISPLAY RESULT
└─> League.render_resolve_copy(resolve_copy, outcome_text)
└─> Fill {{resolve_copy}} (stamped at score) and {{outcome_text}}
(winning choice). Blank outcome returns the phrase only.
Key Method Invocations by Phase¶
Issuance Phase:
QuestionDefinition.is_issuable()→jsonpath_condition_evaluate()QuestionIssuance.create_from_issuance()→QuestionDefinition.extract_issuance_state()→jsonpath_extract()(multiple times)QuestionDefinition.generate_answer_choices()→jsonpath_replace()orjsonpath_extract()
Answer Phase:
QuestionAnswer()constructor (no helper methods)
Issuance resolution:
QuestionIssuance.is_resolvable()→QuestionDefinition.get_resolution_conditions()jsonpath_condition_evaluate()QuestionIssuance.resolve_with_event()→- Sets
resolution_eventandresolved_at - For each answer choice:
jsonpath_condition_evaluate(choice.correct_condition) - Sets
is_correcton each answer choice - Listener commits the issuance
Answer evaluation:
- Listener enqueues
evaluate_question_answers_batch(chunked IDs) evaluate_question_answers_batch_impl()→ for eachQuestionAnswer:QuestionAnswer.evaluate_correctness()reads pre-computed choiceis_correct- Sets
is_correct,points_awarded,resolved_aton the row
Award-selected settlement (Beat, after match terminal):
sweep_stale_issuancesselects unresolved issuances on terminal matches whose answer window has ended. Postpone/cancel/unnecessary also settle expired-window cards on the status-change path.- Sets
resolved_at,resolution_reason(staleafter complete/closed;postponed/cancelled/unnecessarymatching match status), syntheticresolution_event - Enqueues the same answer-evaluation batches; scorer awards each answer’s
selected-choice stamped points and leaves
is_correctnull - Publishes
question.closedwith thatreason
Display Phase:
QuestionIssuance.get_formatted_question_text()→jsonpath_replace()- History / play / push notifications compose
League.render_resolve_copyfrom stampedresolve_copyand winningoutcome_text(leaguedefault_resolve_copytemplate). Legacyget_formatted_success_text()/get_formatted_fail_text()still read snapshot copy when present.
Summary¶
The question issuance and resolution system provides a flexible, event-driven approach to generating and resolving questions based on live sports data. By using JSONPath for data extraction and condition evaluation, the system can handle complex scenarios while maintaining a clean, declarative configuration model.
Key design decisions:
- Separation of concerns:
QuestionDefinition(template) vsQuestionIssuance(instance) vsQuestionAnswer(user response) - Dependent extractions: Later state extractions can reference earlier ones
via
store_statein context - Auto-derivation: Resolution conditions can be automatically derived from answer choice conditions
- Multiple users: Each user's answer is stored separately and evaluated independently (scoring runs in Celery batches after the issuance is committed)
- Dynamic text: All text fields support JSONPath placeholders for dynamic content