Skip to content

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

  1. Overview
  2. Models
  3. Helper Methods
  4. Process Flow
  5. Complete Example
  6. Method Call Sequence

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 question
  • issuance_state_extract: Dictionary mapping keys to JSONPath patterns for extracting state from issuance events
  • answer_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-eid corrective before resolving (0 = immediate unless the play is under review); measured per LISTENER_TIMING_SOURCE (realtime or stream ts). The window does not expire while the play is under review.
  • issuance_correction_window_seconds: Seconds to wait for a same-eid corrective 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 match
  • issuance_retract_on_corrective: When true, a same-eid corrective that no longer matches issuance triggers closes an open issuance as retracted

Key Methods:

  • is_issuable(issuance_event): Checks if question can be issued based on event
  • extract_issuance_state(issuance_event): Extracts state data from issuance event
  • generate_answer_choices(issuance_event): Generates formatted answer choices from template
  • get_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 QuestionDefinition
  • match_id: Internal match id (Match.id) this question is for — not the Sportradar match id
  • issuance_event: The event that triggered the question issuance
  • store_state: Extracted state data needed for resolution
  • answer_choices: Generated answer choices for this specific issuance
  • definition_snapshot: JSON copy of the question definition at issue time (QuestionDefinitionOut shape). 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 (pins definition_snapshot; when match is passed, also pins question_settings_snapshot; stamps issued_in_period, issued_situation, and issued_clock from the event via extract_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 present
  • get_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. Sets resolution_event and resolved_at, evaluates all answer choices' correct_condition fields, and sets is_correct on each answer choice
  • is_resolved(): Returns True if question has been resolved
  • get_formatted_question_text(*, require_snapshot=False): Formatted question text (snapshot template when present; with require_snapshot=True for 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 no success_text / failed_text). Play, history, and push notifications compose via League.render_resolve_copy with stamped resolve_copy and winning outcome_text.
  • definition_is_snapshot: True when definition_snapshot was 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 QuestionIssuance
  • user_id: The user who provided this answer
  • selected_answer_identifier: The identifier of the answer choice selected
  • answered_at: When the user provided this answer
  • is_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 the is_correct flag from the selected answer choice. The flag is set during QuestionIssuance.resolve_with_event(). The resolution_event parameter 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 options
  • to_sec() function: "to_sec({{$.payload.event.clock}})"mm:ss → seconds
  • if_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

  1. Issuance Phase:
  2. Event arrives from event stream
  3. Match-wide gates (question_max_count, period distribution): when a gate is closed, only is_pivotal_moment definitions remain in the pool. Pivotal skip of the match period map does not skip a definition’s issuance_period_distribution or the group period map (unlisted: block treats omitted keys as 0).
  4. QuestionDefinition.select_by_league(league_id, event) picks one active definition that is issuable for the event, whose issuance_group has 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: base issuance_weight, optionally decayed by issuance_weight * (issuance_weight_decay ** times_issued) when issuance_dynamic_weighting is on (session counts on the listener). The listener logs the full candidate name/short-id + weight vector before the pick. If any eligible candidate has is_pivotal_moment, selection is restricted to that subset (still one issue per event).
  5. Selected definition’s issuance_probability gate runs once (optional issuance_probability_max_false_streak pity timer); a false skip means no issuance on that event (no re-pick among other eligible definitions) and does not update dynamic weights
  6. On a successful issue with dynamic weighting on, the listener increments the session issue count and logs the old/new effective weight
  7. QuestionIssuance.create_from_issuance() creates new issuance:
    • Copies issuance_group and issuance_min_interval from the definition
    • Stamps issued_event_ts from the issuance event ts (cross-group waits after issuance)
    • Stamps issued_in_period, issued_situation, and issued_clock from the event (first-match extractors in lib.event_period and lib.event_game_context; new leagues append an extractor)
    • Pins definition_snapshot (JSON of the definition at issue time)
    • Pins question_settings_snapshot when the match row is passed
    • Calls extract_issuance_state() to extract state data
    • Calls generate_answer_choices() to generate answer choices
  8. Issuance saved to database
  9. Optional real-time pause (LISTENER_POST_ISSUANCE_PAUSE_SECONDS) before the next stream event — independent of LISTENER_TIMING_SOURCE; for fast simulator playback
  10. Question displayed to users with formatted text

  11. Answer Phase:

  12. Users submit answers via QuestionAnswer model
  13. Each answer is stored with question_issuance_id, user_id, and selected_answer_identifier

  14. Issuance resolution:

  15. Resolution event arrives from event stream
  16. QuestionIssuance.is_resolvable() checks if the issuance can be resolved (uses pinned definition_snapshot conditions when present; live definition only for legacy null-snapshot rows)
  17. If resolvable and the definition’s resolution_correction_window_seconds is 0, QuestionIssuance.resolve_with_event() runs immediately:
    • Sets resolution_event and resolved_at
    • Evaluates each answer choice's correct_condition with jsonpath_condition_evaluate() and sets choice.is_correct
  18. If the window is > 0, the listener holds the candidate in memory for that many seconds (per LISTENER_TIMING_SOURCE: realtime timer, or stream ts deltas for simulator replay). Same-eid corrective 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.
  19. The listener commits the issuance so choice-level correctness is stored

  20. Answer evaluation:

  21. The listener loads QuestionAnswer IDs for that issuance and enqueues one or more Celery evaluate_question_answers_batch tasks (IDs chunked by ANSWER_EVALUATION_BATCH_SIZE for workload sizing; each task scores every ID it receives)
  22. evaluate_question_answers_batch_impl loads each answer, calls evaluate_correctness() (reads pre-computed choice is_correct), sets QuestionAnswer.is_correct, points_awarded, and resolved_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:

  1. QuestionDefinition.is_issuable()jsonpath_condition_evaluate()
  2. QuestionIssuance.create_from_issuance()
  3. QuestionDefinition.extract_issuance_state()jsonpath_extract() (multiple times)
  4. QuestionDefinition.generate_answer_choices()jsonpath_replace() or jsonpath_extract()

Answer Phase:

  1. QuestionAnswer() constructor (no helper methods)

Issuance resolution:

  1. QuestionIssuance.is_resolvable()
  2. QuestionDefinition.get_resolution_conditions()
  3. jsonpath_condition_evaluate()
  4. QuestionIssuance.resolve_with_event()
  5. Sets resolution_event and resolved_at
  6. For each answer choice: jsonpath_condition_evaluate(choice.correct_condition)
  7. Sets is_correct on each answer choice
  8. Listener commits the issuance

Answer evaluation:

  1. Listener enqueues evaluate_question_answers_batch (chunked IDs)
  2. evaluate_question_answers_batch_impl() → for each QuestionAnswer:
  3. QuestionAnswer.evaluate_correctness() reads pre-computed choice is_correct
  4. Sets is_correct, points_awarded, resolved_at on the row

Award-selected settlement (Beat, after match terminal):

  1. sweep_stale_issuances selects unresolved issuances on terminal matches whose answer window has ended. Postpone/cancel/unnecessary also settle expired-window cards on the status-change path.
  2. Sets resolved_at, resolution_reason (stale after complete/closed; postponed / cancelled / unnecessary matching match status), synthetic resolution_event
  3. Enqueues the same answer-evaluation batches; scorer awards each answer’s selected-choice stamped points and leaves is_correct null
  4. Publishes question.closed with that reason

Display Phase:

  1. QuestionIssuance.get_formatted_question_text()jsonpath_replace()
  2. History / play / push notifications compose League.render_resolve_copy from stamped resolve_copy and winning outcome_text (league default_resolve_copy template). Legacy get_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) vs QuestionIssuance (instance) vs QuestionAnswer (user response)
  • Dependent extractions: Later state extractions can reference earlier ones via store_state in 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