Skip to content

Question definition editor

The workspace at /admin/question-definitions maintains effective QuestionDefinition rows in PostgreSQL. Authoring is league-scoped (MLB, NHL, NFL); sample Field Browser content is richest for MLB today.

For field semantics and runtime behavior, also read Question definitions and League event / store_state contracts. To inspect how a definition inherits league / match / group settings, use Question Inheritance. To see why a published question issued, use Issuance Explorer.

Persistence model

  • Save writes only to PostgreSQL.
  • Edits are held as in-memory drafts until Save to DB.
  • The listener consumes database rows at runtime. Bulk create/update is also available via Export to CSV / Import from CSV (preview then confirm). There is no JSON seed import path for question definitions.
  • id, league_id, created_at, and updated_at are display-only in the editor and cannot be overwritten through Save to DB or CSV import.
  • Definitions are unique by name within the selected league at the API layer.

Operator workflow

  1. Open /admin/question-definitions. A deep link (?league=mlb&id=<definition uuid> or ?folder=Pre-game/Winner) opens that definition’s editor or folder after the list loads. league may be the slug or the Python enum form (LeagueTypes.MLB).
  2. Use the filter bar above the list (League, Search, Pivotal, Date filter, Show archived). With no definition selected, the expanded overview table shows name, folder, status (archived / active / inactive — active means included in league defaults), group, weight, probability, id, and timestamps (click column headers to sort). Click a row to open the editor; the folder tree then sits in the sidebar. The expand-list icon (next to Rename folder) returns to the table. Click a folder or breadcrumb to drill in; New folder / Rename folder rewrite descendant paths. Drag a definition or folder to move it (cannot drop a folder into itself). Tree order is name-sorted and not persisted. The issuance Group slug stays a badge, not a folder. New definition inherits the folder you are browsing (or the selected group’s name at root). Changing league reloads that league’s definitions (unsaved drafts for other leagues are kept until Save to DB). Folder moves are drafts until Save to DB.
  3. Select an existing definition or choose New definition.
  4. Work through the guided tabs. Switching definitions keeps in-memory drafts; each dirty rule shows an unsaved indicator in the sidebar.
  5. On Issuance, write player-facing copy and run preview with the same event type used for issuance.
  6. Review Raw JSON when advanced control is needed.
  7. Choose Save to DB. A confirmation modal summarizes field-level changes for every dirty definition before POST/PUT runs.

Unsaved drafts are protected when leaving the page (admin navigation, refresh, or close). Switching between rules does not prompt to discard.

Copy all JSON fetches each listed definition’s full row (preferring local drafts when present) and copies a JSON array of writable editor documents to the clipboard (useful for backup or external review).

Export to CSV downloads every in-game definition across all leagues (including archived), not just the league in the filter bar. Columns are the live question_definitions table fields plus a league slug; JSON columns are written as JSON strings. The same file is GET /api/question-definitions/export.

Import from CSV uploads that same export-shaped file. The API validates every row and shows creates, field-level updates, and errors before anything is written (POST /api/question-definitions/import?dry_run=true). Confirm applies all creates and updates in one transaction (dry_run=false); any row error refuses the whole import. Blank id creates a definition; an id that is not in the database is an error. Rows missing from the file are not deleted. Changing a definition does not rewrite already-issued questions.

Keep JSON cells intact. Desktop spreadsheet apps often break JSON and UUIDs.

Overview

  • Name: operations label, unique within MLB; not player-facing.
  • Include in league defaults: when on, matches that inherit the league allowlist may issue this definition. New and cloned rows start off. Turning on requires at least one issuance trigger. Does not archive.
  • Folder: slash-separated admin path (empty = unfiled). Organization only — changing folder does not change issuance group. Unlimited nesting.
  • Pivotal moment: skips match max questions and the match normal period cap (does not consume them) and wins selection over routine props on the same event. Still uses match-wide max pivotal moments (league default 4) and the per-period pivotal cap ([normal, pivotal] or even-fill default 1). Group / definition period maps and Max issuances per match still apply. Shows a sidebar//play badge.
  • Override question points: optional question-effective points. Leave empty to inherit match override → league default. Absolute (e.g. 10) or multiplier of the effective match/league value (e.g. *2).

Issuance

Issuance includes:

  1. Question text — player-facing copy at the top of the Issuance tab, with store_state insert chips. Right/wrong phrases are league defaults, not on this definition.
  2. Issuance group / min interval / weight — pick an existing question group from the dropdown (no free-text slug). Manage groups opens the groups page for this league. Post-resolve wait is max(match floor, live group interval, this definition snapshot). Relative selection weight (plus optional dynamic weighting / weight decay).
  3. Probability & match caps — after weighted selection picks one definition, that winner’s percent roll (default 100) decides whether to issue. Other eligible definitions are not rolled; a skip means no question on that event. Max false streak (default 0 = off) forces a true after consecutive skips of this definition.
  4. Period distribution (this definition) — optional int-only period/half caps for this question ({} = none). unlisted: block means only listed keys may issue. Pivotal skip of the match map does not skip this map.
  5. Max issuances per match (this definition) — optional hard cap on how many times this type may publish in one match (empty = unlimited, 0 = never). Retracted rows do not count. MLB standard types typically use 2; leave empty on pivotal. Pivotal skip of match max does not skip this cap.
  6. Subject extract — optional JSONPath for the player this question is about (evaluated after store_state). Empty = team / situation (no uniqueness). At most one non-pivotal in-game question per subject per period, match-wide (not limited to this definition’s question group). Pivotal skip this gate and do not occupy the slot.
  7. Issuance correction window — wait for a same-event-id feed correction before publishing (default 0 = create immediately). While deferred, the question is not in the DB; a corrective may update or drop the candidate.
  8. Start issuance on corrective — allow a correction to begin issuing when triggers first match on the corrected payload (pair with a positive issuance correction window when first delivery is incomplete).
  9. Retract on corrective — after publish, close the open question if a same-event-id correction no longer matches issuance triggers (0 points; does not count toward period/max). Prefer the issuance correction window when you can wait before publish.
  10. Trigger conditions decide whether an incoming event can select the definition. Conditions in the list are OR'ed.
  11. State extract copies values from the issuing event into persisted store_state.
  12. Sample-event preview — question text only, at the bottom of the tab. Unresolved placeholders usually mean the selected sample lacks that path.

Example MLB trigger:

{{$.payload.event.type}} == "atbat"

Typical MLB at-bat state:

{
  "at_bat_id": "$.payload.event.id",
  "batter_name": "$.payload.event.hitter.full_name"
}

State-extract rows execute top to bottom. Later rows may read values written by earlier rows through $.store_state.<key>.

Extract helpers (source column may be more than a plain path):

Pattern Meaning
$.payload.event.hitter.full_name Plain JSONPath (copy from Field browser)
$.path ~ fallback Default when the path is missing (or trailing after a helper: to_sec(…) ~ 0)
rand({{$.a}}, {{$.b}}, …) Pick one argument at random
to_sec({{$.payload.event.clock}}) Convert mm:ss clock to total seconds
if_eq(actual, expected, then, else) If actual == expected return then, else else

Example MLB batting team on half_start (top = away, bottom = home):

if_eq({{$.payload.event.inning_half}}, 'T', {{$.payload.game.away.name}}, {{$.payload.game.home.name}}) ~ batting team

Open the ? help on Issuance state extract or Field browser for the same catalog (qd_extract_helpers in field_help.js). The Field browser also lists read-only $.match_state.* keys the listener publishes for the selected league (not configured per definition). Open ? on the match_state heading or on a listed key for meaning, when it updates, and when it resets.

Answer Choices

Each answer choice includes:

  • Identifier: stable stored answer value, commonly yes or no.
  • Display text: player-facing label; may be static, a direct JSONPath, or a template.
  • Outcome text: optional on-field line when this choice wins. Same placeholders as display text. Right/wrong phrases live on League Defaults (default_resolve_*_copies), stamped as resolve_copy at score. Play REST, question.closed, and history send the winning outcome_text after resolve.
  • Correct condition: expression evaluated against later resolution events plus store_state.

Choice order in the editor is player-facing order. Avoid changing identifiers after users have submitted answers.

Each choice may also set Override points (override_points) with the same absolute / *N syntax as Overview’s question points override. Blank inherits the question-effective value. Use this for asymmetric awards (for example more points for Yes than No). Open/in-play clients see the resolved points on each choice.

Resolution

On a later feed event the listener decides (a) whether the open question may close, and (b) which answers are correct. The event that triggers closing is the same event used to evaluate answer correct_conditions. Answer conditions on the Answers tab always own (b). By default they also own (a). Explicit resolution triggers are an optional override for (a) only.

  • Empty (default): the question may close when any answer correct_condition matches; that same closing event marks the winner. Leave empty for most questions.
  • If set: the question may close only when a trigger matches. That closing event is then used to evaluate answer conditions (triggers do not mark Yes/No). If no answer matches, the question still closes with no winner (players get 0).
  • Example: trigger waits for MLB is_ab_over; Yes/No conditions on Answers score that same terminal pitch. Put event_over on No’s correct_condition (not only in triggers) if walk-offs should resolve No.
  • Correction window 0 resolves immediately unless the play is under review. A positive value waits for a same-event-ID corrective update before finalizing. The window does not expire while the play is under review; after the feed marks it final, or after a short hold cap, the configured seconds run as call-on-the-field.
  • Start resolution on corrective (default on for new definitions): a matching feed correction of the same event may begin waiting/resolving, not only update a wait that already started. The first delivery can still start resolve if it already matches. Off = a correction may only update a resolve already waiting. Existing rows keep their saved value until you save them. See the ? help. Added after TOR @ WSH (2026-07-29), where a half-inning “Will a run score?” stayed pending while runs appeared only on feed corrections, then resolved No at half end. The inverse: a late rewrite of an earlier play can start resolve if close / No is “any event from a different unit” (MLB TOR @ TB, 2026-08-20 — prior-batter strikeout locked the new batter’s hit question; MLB CLE / Jays 2026-09-01 — MLB At Bat Number of Balls locked with no winning choice because the same clause was an explicit resolution trigger). Use the next unit’s start event, not any later event whose unit id differs. Do not turn this flag off to paper over that. New issuances pin the setting; already-open questions keep their old snapshot.

Field Browser

The Field Browser provides searchable paths, types, and representative values from a real MLB sample stream. Select:

  1. An event type
  2. A path

Use Copy on a path to place the path on the clipboard, then paste it into the appropriate editor field (often an issuance state-extract source). Paths are plain JSONPaths; wrap them in extract helpers (rand, to_sec, if_eq) when needed — see the ? help on this tab and the helper table under Issuance above. The Field Browser does not modify the definition. Success and error feedback uses the shared admin toast (AdminToast in base.js); keep field-level notes (for example Raw JSON parse errors) inline beside the field.

Raw JSON

Raw JSON exposes the complete row shape:

  • Format: prettifies valid JSON.
  • Reset from form: discards raw-editor changes and regenerates JSON from guided controls.
  • Apply JSON → form: parses valid JSON into guided controls.
  • Save to DB: validates and persists editable fields for all dirty drafts.

Invalid JSON remains in the editor with an error and is not applied.

Sample catalog

The browser calls:

GET /api/question-definitions/sample-catalog?league=mlb&include_samples=true

src/lib/question_definitions/sample_catalog.py currently resolves this file:

src/lib/question_definitions/fixtures/mlb_sample.json

The server:

  1. Loads the JSON stream.
  2. Keeps the first representative item for each payload.event.type.
  3. Recursively extracts JSONPath-like paths, value types, and examples.
  4. Returns a compact catalog instead of the full stream.
  5. Caches the generated catalog in process with lru_cache.

The sample path is hard-coded for MLB. Changing the file on disk does not invalidate an already populated process cache; restart the API or call the cache-clear helper in tests.

REST API

Question definition CRUD lives outside /admin, as a normal REST resource:

Method Path Purpose
GET /api/question-definitions?league={league} List definitions
POST /api/question-definitions?league={league} Create
GET /api/question-definitions/{definition_id} Read one
PUT /api/question-definitions/{definition_id} Replace editable fields
POST /api/question-definitions/{definition_id}/clone Clone
POST /api/question-definitions/{definition_id}/archive Set is_archived=true (hidden from default tree)
POST /api/question-definitions/{definition_id}/activate Set is_archived=false (show in tree again; does not enable league defaults)
DELETE /api/question-definitions/{definition_id} Hard-delete when no issuances/sponsorships
GET /api/question-definitions/export CSV of every league (including archived)
POST /api/question-definitions/import?dry_run= Preview (true) or apply (false) that CSV
POST /api/question-definitions/preview Render copy against a sample
GET /api/question-definitions/sample-catalog?league={league} Field catalog

definition_id is the internal question-definition UUID. It is not a Sportradar match or event ID. List/create/sample-catalog are league-scoped via the league query parameter; get/update/clone/archive/activate are ID-scoped.

Code map

Concern Location
REST routes src/api/routes/question_definitions.py
Admin HTML page src/api/admin/routes/question_definitions.py
Workspace template src/api/admin/templates/question_definitions/workspace.jinja2
Editor state src/api/admin/static/js/question_definitions_app.js
Serialization helpers src/api/admin/static/js/question_definitions_model.js
Feature CSS src/api/admin/static/css/question_definitions.css
CRUD helpers src/lib/question_definitions/ops.py
CSV import src/lib/csv_import.py
Request/response schemas src/lib/question_definitions/schemas.py
Sample catalog src/lib/question_definitions/sample_catalog.py
API tests src/api/tests/test_question_definitions.py
Admin HTML tests src/api/tests/admin/test_question_definitions.py

Common failures

  • MLB league not found: seed the leagues row before using the editor.
  • Duplicate name: choose a unique name within MLB.
  • Sample catalog missing: confirm the configured sample JSON exists in the repository at the documented path.
  • Preview leaves placeholders: verify event type, extraction path, and store_state key spelling.
  • Save returns validation errors: inspect nested trigger and answer-choice fields; Raw JSON must match the Pydantic schema. Active definitions need at least one evaluable issuance trigger; blank/broken condition strings and blank extract values are rejected. Inactive drafts may keep an empty trigger list, but Activate will fail until a trigger is added.